jampress.pw
PhilosophyDocsRoadmapPlaygroundGitHub

Documentation

PhilosophyThree tracks, modular scaling, and the foundation rules that shape every package.ArchitectureStable viewer contracts with swappable provider/storage internals.Astro Site BlueprintsFleet, collection, site, storage-topology, and downstream release boundaries.Capability FoundryReusable upstream capabilities with independent product and owner repositories.SecurityThreat model, token/session controls, and rate-limit requirements.RoadmapPhase-by-phase path from DIY auth to WordPress bridge and adapters.Auth Adoption PlanConcrete rollout order and milestones for zip, lobpress, inbox, DesBio, and DBScript.Host App IntegrationHow Astro and Workers apps wire middleware, hooks, and routes.Provider AdaptersThe AuthAdapter contract and migration path across login providers.Data ModelD1/MySQL schemas for identities, sessions, tokens, and rate limits.

Host App Integration

Overview

Integrating jampress auth into your Astro/Cloudflare Workers app involves four steps:

  1. Install packages
  2. Configure the auth instance
  3. Wire middleware
  4. Implement the HostHooks callback

Step 1: Install

The auth packages are private, unpublished, and UNLICENSED. Create an authorized, checksummed bundle with pnpm private:export, then use the bundle's private-package-export.json as the input to the host-side sync. Install all four exact tarballs and map their internal exact dependencies through the host's pnpm.overrides; never use a mutable Git branch or the Jampress workspace as an independent host dependency. See docs/PRIVATE_PACKAGE_EXPORTS.md.

Astro 7 baseline

The tested host matrix is Astro 7.2.4 with @astrojs/cloudflare 14.2.3, Wrangler 4.125.0, and Node 22.12 or newer. The Jampress adapter's Astro peer range is >=7.2.0 <8; Astro 6 is not a release target.

// astro.config.mjs
import cloudflare from '@astrojs/cloudflare';
import { defineConfig } from 'astro/config';

export default defineConfig({
  output: 'server',
  adapter: cloudflare({ imageService: 'compile' }),
  session: false,
});

session: false is important: Jampress owns the browser session. Leaving Astro's separate session feature enabled can silently require another Cloudflare KV binding. A working reference host lives in examples/invite-only-astro and is rebuilt from packed tarballs by pnpm private:pack-smoke.

Step 2: Configure

Create src/lib/auth.ts in your host app:

import {
  createAuthInstance,
  createZeptomailSender,
} from '@jampress/auth-astro-workers';
import { createMagicLinkProvider } from '@jampress/auth-provider-magic-link';
import { createD1Storage } from '@jampress/auth-storage-d1';
import { env as cloudflareEnv } from 'cloudflare:workers';

export function createAuth() {
  const storage = createD1Storage(cloudflareEnv.AUTH_DB);
  const email = createZeptomailSender({
    secret: cloudflareEnv.ZEPTOMAIL_SECRET,
    fromAddress: 'login@jampress.pw',
  });

  const provider = createMagicLinkProvider({
    storage,
    email,
    authorizeIdentity: async (identity) => {
      const member = await cloudflareEnv.AUTH_DB.prepare(
        `SELECT status FROM app_access_members WHERE email = ?1 LIMIT 1`,
      ).bind(identity.email).first<{ status: string }>();

      return member?.status === 'invited' || member?.status === 'active';
    },
    buildMagicLinkUrl(token, redirectTo) {
      const url = new URL('/confirm', 'https://myapp.com');
      url.searchParams.set('token', token);
      if (redirectTo) url.searchParams.set('redirectTo', redirectTo);
      return url.toString();
    },
  });

  return createAuthInstance({
    provider,
    storage,
    email,
    session: {
      cookieName: 'myapp_session', // default: 'jp_session'
      ttlSeconds: 60 * 60 * 24 * 7, // default: 7 days
    },
    routes: {
      requestLogin: '/api/auth/request',  // default
      completeLogin: '/api/auth/verify',  // default
      logout: '/api/auth/logout',         // default
      confirm: '/confirm',                // for subscriber auth widget
    },
    csrf: {
      allowedOrigins: ['https://myapp.com', 'http://localhost:4321'],
    },
  });
}

Step 3: Wire middleware

In an invite-only host's src/middleware.ts:

import { defineMiddleware } from 'astro:middleware';
import { createAuth } from './lib/auth';
import { activateAndResolveViewer } from './lib/host-policy';
import { env as cloudflareEnv } from 'cloudflare:workers';

export const onRequest = defineMiddleware(async (context, next) => {
  const response = await createAuth().middleware({
    // This lookup may activate an existing invite, but it never creates an
    // unknown member. It projects host-owned roles and capabilities.
    resolveOrCreateViewer: (identity) =>
      activateAndResolveViewer(cloudflareEnv.AUTH_DB, identity),
  })(context, next);

  if (!response) throw new Error('Jampress middleware returned no response.');
  const headers = new Headers(response.headers);
  headers.set('Cache-Control', 'private, no-store');
  return new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers,
  });
});

Astro 7 on Cloudflare Workers does not expose Astro.locals.runtime.env. Use import { env } from 'cloudflare:workers' inside server-side modules instead. The framework adapter copies the session cookie into the x-jampress-session-id header before calling the provider, and then fills viewer.auth.sessionId on Astro.locals.viewer.

ZEPTOMAIL_SECRET is a per-application Worker secret. There is deliberately no public shared fallback relay. You can provide another authenticated EmailSender, but a sender-domain claim alone is not sufficient authentication. The address login@jampress.pw is branding; the per-application secret is the credential.

Cloudflare Email Sending is also supported through a host-owned binding:

import { createCloudflareEmailSender } from "@jampress/auth-astro-workers";

const email = createCloudflareEmailSender({
  binding: cloudflareEnv.EMAIL,
  fromAddress: "login@jampress.pw",
});
{
  "send_email": [
    {
      "name": "EMAIL",
      "allowed_sender_addresses": ["login@jampress.pw"]
    }
  ]
}

The configured fromAddress is authoritative; message-level from overrides are ignored. Select one provider per deployment. Jampress does not automatically fall back between providers because an ambiguous provider timeout could produce duplicate authentication emails.

The Cloudflare sender returns its provider messageId. With the D1 adapter's fifth migration applied, Jampress records that ID and the initial accepted state in auth_email_deliveries. Hosts can subscribe a Cloudflare Queue to the sending domain's message.delivered, message.deferred, message.bounced, message.failed, message.rejected, and message.complained events, then use:

import { parseCloudflareEmailSendingEvent } from "@jampress/auth-astro-workers";
import { applyEmailDeliveryEvent } from "@jampress/auth-storage-d1";

const event = parseCloudflareEmailSendingEvent(message.body, {
  expectedDomain: "jampress.pw",
});
if (event) await applyEmailDeliveryEvent(env.AUTH_DB, event);

applyEmailDeliveryEvent updates only a provider message ID already recorded in that host's D1. This is the privacy boundary when several isolated host apps share one sending domain: an unknown message is ignored rather than imported. Queue consumers should acknowledge malformed and uncorrelated messages, retry D1 failures, omit recipient/message details from Worker logs, and use a dead letter queue.

Invite-only access policy

Jampress proves identity and manages sessions; the host application decides who is allowed in. A deliberately small host-owned table is enough:

CREATE TABLE app_access_members (
  email TEXT PRIMARY KEY COLLATE NOCASE,
  status TEXT NOT NULL CHECK (status IN ('invited', 'active', 'revoked')),
  role TEXT NOT NULL CHECK (role IN ('member', 'editor', 'admin')),
  capabilities_json TEXT NOT NULL DEFAULT '[]',
  invited_by TEXT,
  invited_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
  activated_at TEXT,
  revoked_at TEXT,
  updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Pass an authorizeIdentity callback to createMagicLinkProvider, as shown above. Jampress invokes it at three phases:

  • request: before creating a token or sending email. A false result still produces the generic request-success response.
  • complete: after consuming the one-time token but before creating an identity record or session.
  • session: on every authenticated request. A false result deletes that session immediately, so revocation does not wait for TTL expiry.

The host's resolveOrCreateViewer callback should read role/account data from this same application database. Unknown emails must never be inserted merely because they requested a link.

The full reference implementation—including activation race handling and capability parsing—is in examples/invite-only-astro/src/lib/host-policy.ts.

Scanner-safe confirmation routes

The email link must target a GET confirmation page, not the consuming endpoint. createConfirmHandler renders a no-store interstitial with an explicit POST form:

// src/pages/confirm.ts
export const GET = (context) => createConfirmHandler({
  authInstance: createAuth(),
  hooks,
  authRedirect: '/dashboard',
  authVerifyPath: '/api/auth/verify',
  subscribeRedirect: '/?subscribed=1',
})(context);

// src/pages/api/auth/verify.ts
export const POST = async (context) => {
  const auth = createAuth();
  // Register host hooks before completion so the new viewer is projected into
  // locals as well as receiving a session cookie.
  auth.middleware(hooks);
  const response = await auth.handleCompleteLogin(context);
  const payload = await response.clone().json().catch(() => null);
  return response.ok && payload?.success
    ? context.redirect(payload.redirectTo || '/dashboard', 303)
    : response;
};

The adapter accepts only POST for request, verify, and logout handlers, checks the configured Origin, and accepts only same-origin application paths such as /dashboard for redirectTo. Absolute URLs and protocol-relative paths fail.

Step 4: Use in your app

In any Astro page or API route:

// Pages
const viewer = Astro.locals.viewer;
if (!viewer) {
  return Astro.redirect('/get-started');
}
// viewer.user.email, viewer.account.id, etc.

// API routes
export async function POST({ locals }: APIContext) {
  if (!locals.viewer) {
    return new Response('Unauthorized', { status: 401 });
  }
  // Use locals.viewer.user, locals.viewer.account
}

D1 setup

The auth storage adapter needs its own tables. Each host owns its database; never point two applications at a playground, shared Jampress, or another product's identity/session state.

Recommended: one dedicated host-auth D1

For a standalone invite-only app, use one database containing Jampress's namespaced auth_* tables plus that host's membership, role, capability, and audit tables. "One database" means one database dedicated to that host—not a central Jampress identity database.

# wrangler.toml
[[d1_databases]]
binding = "AUTH_DB"
database_name = "myapp-auth"
database_id = "..."

Both createD1Storage() and host authorization use this binding:

createD1Storage(cloudflareEnv.AUTH_DB);
authorizeHostIdentity(cloudflareEnv.AUTH_DB, identity, context);

The installed package includes the raw ordered auth migrations. Apply them before the host migration that creates app_access_members:

for migration in node_modules/@jampress/auth-storage-d1/migrations/*.sql; do
  pnpm exec wrangler d1 execute AUTH_DB --local --file "$migration"
done
pnpm exec wrangler d1 execute AUTH_DB --local --file migrations/0001_host_members.sql

Production migration execution is a separate, explicitly approved operation; replace --local only during the deployment procedure. Applications with an existing host-only D1 may place the same namespaced tables there, but must not share that database across applications.

Subscriber auth widget

To add the dual-purpose subscribe/auth form:

import { createSubscribeHandler, createConfirmHandler } from '@jampress/auth-widget-subscribe';
import { createAuth } from './lib/auth';

const hooks = {
  resolveOrCreateViewer,
  onNewSubscriber: async (email) => {
    // Add to your mailing list: D1 table, Mailchimp, ConvertKit, webhook, etc.
    await addSubscriber(email);
  },
};

export const POST = (context) => {
  const auth = createAuth();
  return createSubscribeHandler({
    provider: auth.provider,
    storage: auth.storage,
    email: auth.email,
    hooks,
    confirmUrl: `${context.url.origin}/confirm`,
  })(context);
};

export const GET = (context) => {
  const auth = createAuth();
  return createConfirmHandler({
    authInstance: auth,
    hooks,
    authRedirect: '/dashboard',
    authVerifyPath: '/api/auth/verify',
    subscribeRedirect: '/?subscribed=1',
  })(context);
};

The widget renders a single email input. Add it to your footer layout:

<SubscribeForm action="/api/subscribe" placeholder="Enter your email" buttonText="Subscribe" />

Both known users and new subscribers see the same UI response. The email they receive looks identical — same subject, same template. The confirm link either logs them in or confirms their subscription.

Migrating from existing auth

If your app already has inline auth (like zip.wtf's current setup):

  1. Install the SDK packages
  2. Create the auth config pointing at your existing D1
  3. Run the auth migration to add auth_identities and auth_sessions tables
  4. Migrate existing user records into auth_identities
  5. Replace your middleware's resolveViewer() with the SDK's middleware
  6. Replace your route handlers with the SDK's route handlers
  7. Update your get-started page to use the SDK's request endpoint
  8. Test the full flow: request → email → verify → session → dashboard
  9. Remove old auth code

The viewer contract stays the same — your app pages shouldn't need any changes.