Host App Integration
Overview
Integrating jampress auth into your Astro/Cloudflare Workers app involves four steps:
- Install packages
- Configure the auth instance
- Wire middleware
- Implement the
HostHookscallback
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. Afalseresult 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. Afalseresult 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):
- Install the SDK packages
- Create the auth config pointing at your existing D1
- Run the auth migration to add
auth_identitiesandauth_sessionstables - Migrate existing user records into
auth_identities - Replace your middleware's
resolveViewer()with the SDK's middleware - Replace your route handlers with the SDK's route handlers
- Update your
get-startedpage to use the SDK's request endpoint - Test the full flow: request → email → verify → session → dashboard
- Remove old auth code
The viewer contract stays the same — your app pages shouldn't need any changes.