@braedonsaunders/appkit-auth 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # @braedonsaunders/appkit-auth
2
+
3
+ Production authentication extracted from the complete reference runtime and generalized around explicit application inputs.
4
+
5
+ ## What ships
6
+
7
+ - persisted Better Auth sessions, password accounts, OAuth accounts, and single-use verification records;
8
+ - password sign-in, reset requests, reset completion, session refresh/revocation, and encrypted OAuth tokens;
9
+ - hashed one-time magic links with framework-neutral email delivery;
10
+ - membership-targeted invitation grants that cannot activate a different user, tenant, membership, or resend generation;
11
+ - atomic Postgres invitation acceptance and audit through `@braedonsaunders/appkit-auth/drizzle`;
12
+ - deterministic memory invitation storage for tests and database-free applications;
13
+ - React sign-in, forgot-password, reset-password, and auth-screen compositions;
14
+ - Better Auth React client and Next.js route bindings as optional entry points.
15
+
16
+ ## Server
17
+
18
+ ```ts
19
+ import { createAppkitAuth, createLazyAuth } from '@braedonsaunders/appkit-auth/server'
20
+
21
+ export const getAuth = createLazyAuth(() => createAppkitAuth({
22
+ database: pool,
23
+ baseURL: process.env.APP_URL!,
24
+ secret: process.env.BETTER_AUTH_SECRET!,
25
+ appName: 'My app',
26
+ sendEmail: (message) => emailQueue.enqueue(message),
27
+ }))
28
+ ```
29
+
30
+ Mount `toNextJsHandler(getAuth())` from `@braedonsaunders/appkit-auth/next` in a Next.js catch-all route. Other frameworks can mount `getAuth().handler` directly.
31
+
32
+ ## Invitations
33
+
34
+ Create one `InviteService` with an explicit signing secret and a memory or Drizzle store. Mint a new grant after atomically advancing `membership.invitedAt`, pass its callback path to `signInMagicLink`, and provide the same service to `createAppkitAuth`. Membership activation then occurs only after Better Auth successfully consumes the matching one-time magic link.
35
+
36
+ Applications own branding, transactional email transport, route authorization, tenant selection cookies, and optional post-acceptance domain effects. The package owns the security state machine and complete UI/runtime boundary.
package/client.d.ts ADDED
@@ -0,0 +1,129 @@
1
+ import { createAuthClient } from 'better-auth/react';
2
+ export type AppkitAuthClientOptions = {
3
+ baseURL?: string;
4
+ basePath?: string;
5
+ fetchOptions?: Parameters<typeof createAuthClient>[0] extends infer Options ? Options extends {
6
+ fetchOptions?: infer FetchOptions;
7
+ } ? FetchOptions : never : never;
8
+ };
9
+ /** React client with password, session, reset, and magic-link APIs. */
10
+ export declare function createAppkitAuthClient(options?: AppkitAuthClientOptions): import("better-auth/react").ReactAuthClient<{
11
+ plugins: {
12
+ id: "magic-link";
13
+ version: string;
14
+ $InferServerPlugin: ReturnType<(options: import("better-auth/plugins").MagicLinkOptions) => {
15
+ id: "magic-link";
16
+ version: string;
17
+ endpoints: {
18
+ signInMagicLink: import("better-auth").StrictEndpoint<"/sign-in/magic-link", {
19
+ method: "POST";
20
+ requireHeaders: true;
21
+ body: import("zod").ZodObject<{
22
+ email: import("zod").ZodEmail;
23
+ name: import("zod").ZodOptional<import("zod").ZodString>;
24
+ callbackURL: import("zod").ZodOptional<import("zod").ZodString>;
25
+ newUserCallbackURL: import("zod").ZodOptional<import("zod").ZodString>;
26
+ errorCallbackURL: import("zod").ZodOptional<import("zod").ZodString>;
27
+ metadata: import("zod").ZodOptional<import("zod").ZodRecord<import("zod").ZodString, import("zod").ZodAny>>;
28
+ }, import("zod/v4/core").$strip>;
29
+ metadata: {
30
+ openapi: {
31
+ operationId: string;
32
+ description: string;
33
+ responses: {
34
+ 200: {
35
+ description: string;
36
+ content: {
37
+ "application/json": {
38
+ schema: {
39
+ type: "object";
40
+ properties: {
41
+ status: {
42
+ type: string;
43
+ };
44
+ };
45
+ };
46
+ };
47
+ };
48
+ };
49
+ };
50
+ };
51
+ };
52
+ }, {
53
+ status: boolean;
54
+ }>;
55
+ magicLinkVerify: import("better-auth").StrictEndpoint<"/magic-link/verify", {
56
+ method: "GET";
57
+ query: import("zod").ZodObject<{
58
+ token: import("zod").ZodString;
59
+ callbackURL: import("zod").ZodOptional<import("zod").ZodString>;
60
+ errorCallbackURL: import("zod").ZodOptional<import("zod").ZodString>;
61
+ newUserCallbackURL: import("zod").ZodOptional<import("zod").ZodString>;
62
+ }, import("zod/v4/core").$strip>;
63
+ use: ((inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<void>)[];
64
+ requireHeaders: true;
65
+ metadata: {
66
+ openapi: {
67
+ operationId: string;
68
+ description: string;
69
+ responses: {
70
+ 200: {
71
+ description: string;
72
+ content: {
73
+ "application/json": {
74
+ schema: {
75
+ type: "object";
76
+ properties: {
77
+ session: {
78
+ $ref: string;
79
+ };
80
+ user: {
81
+ $ref: string;
82
+ };
83
+ };
84
+ };
85
+ };
86
+ };
87
+ };
88
+ };
89
+ };
90
+ };
91
+ }, {
92
+ token: string;
93
+ user: {
94
+ id: string;
95
+ createdAt: Date;
96
+ updatedAt: Date;
97
+ email: string;
98
+ emailVerified: boolean;
99
+ name: string;
100
+ image?: string | null | undefined;
101
+ };
102
+ session: {
103
+ id: string;
104
+ createdAt: Date;
105
+ updatedAt: Date;
106
+ userId: string;
107
+ expiresAt: Date;
108
+ token: string;
109
+ ipAddress?: string | null | undefined;
110
+ userAgent?: string | null | undefined;
111
+ };
112
+ }>;
113
+ };
114
+ rateLimit: {
115
+ pathMatcher(path: string): boolean;
116
+ window: number;
117
+ max: number;
118
+ }[];
119
+ options: import("better-auth/plugins").MagicLinkOptions;
120
+ }>;
121
+ }[];
122
+ baseURL?: string;
123
+ basePath?: string;
124
+ fetchOptions?: Parameters<typeof createAuthClient>[0] extends infer Options ? Options extends {
125
+ fetchOptions?: infer FetchOptions;
126
+ } ? FetchOptions : never : never;
127
+ }>;
128
+ export type AppkitAuthClient = ReturnType<typeof createAppkitAuthClient>;
129
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AAGpD,MAAM,MAAM,uBAAuB,GAAG;IACpC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,YAAY,CAAC,EAAE,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAC,CAAC,CAAC,SAAS,MAAM,OAAO,GACvE,OAAO,SAAS;QAAE,YAAY,CAAC,EAAE,MAAM,YAAY,CAAA;KAAE,GACnD,YAAY,GACZ,KAAK,GACP,KAAK,CAAA;CACV,CAAA;AAED,uEAAuE;AACvE,wBAAgB,sBAAsB,CAAC,OAAO,GAAE,uBAA4B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;6BAQu6I,CAAC;;;;;;;;;iCAAsN,CAAC;iCAA8C,CAAC;;;;;;;;;;;;cAlB9uJ,MAAM;eACL,MAAM;mBACF,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAC,CAAC,CAAC,SAAS,MAAM,OAAO,GACvE,OAAO,SAAS;QAAE,YAAY,CAAC,EAAE,MAAM,YAAY,CAAA;KAAE,GACnD,YAAY,GACZ,KAAK,GACP,KAAK;GASV;AAED,MAAM,MAAM,gBAAgB,GAAG,UAAU,CAAC,OAAO,sBAAsB,CAAC,CAAA"}
package/client.js ADDED
@@ -0,0 +1,10 @@
1
+ import { createAuthClient } from 'better-auth/react';
2
+ import { magicLinkClient } from 'better-auth/client/plugins';
3
+ /** React client with password, session, reset, and magic-link APIs. */
4
+ export function createAppkitAuthClient(options = {}) {
5
+ return createAuthClient({
6
+ ...options,
7
+ plugins: [magicLinkClient()],
8
+ });
9
+ }
10
+ //# sourceMappingURL=client.js.map
package/client.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AACpD,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAA;AAY5D,uEAAuE;AACvE,MAAM,UAAU,sBAAsB,CAAC,UAAmC,EAAE;IAC1E,OAAO,gBAAgB,CAAC;QACtB,GAAG,OAAO;QACV,OAAO,EAAE,CAAC,eAAe,EAAE,CAAC;KAC7B,CAAC,CAAA;AACJ,CAAC","sourcesContent":["import { createAuthClient } from 'better-auth/react'\nimport { magicLinkClient } from 'better-auth/client/plugins'\n\nexport type AppkitAuthClientOptions = {\n baseURL?: string\n basePath?: string\n fetchOptions?: Parameters<typeof createAuthClient>[0] extends infer Options\n ? Options extends { fetchOptions?: infer FetchOptions }\n ? FetchOptions\n : never\n : never\n}\n\n/** React client with password, session, reset, and magic-link APIs. */\nexport function createAppkitAuthClient(options: AppkitAuthClientOptions = {}) {\n return createAuthClient({\n ...options,\n plugins: [magicLinkClient()],\n })\n}\n\nexport type AppkitAuthClient = ReturnType<typeof createAppkitAuthClient>\n"]}
package/drizzle.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
2
+ import type { AppkitDb } from '@braedonsaunders/appkit-db';
3
+ import type { InviteStore } from './invites.js';
4
+ import * as appkitSchema from '@braedonsaunders/appkit-db/schema';
5
+ type Schema = typeof appkitSchema;
6
+ type Database = NodePgDatabase<Schema>;
7
+ export type DrizzleInviteStoreOptions = {
8
+ /** Optional domain hook committed in the same transaction as acceptance. */
9
+ onAccepted?: (db: Database, tenantId: string, userId: string) => Promise<void>;
10
+ /** Customize the audit vocabulary without changing the state machine. */
11
+ audit?: {
12
+ entityType?: string;
13
+ action?: string;
14
+ summary?: string;
15
+ };
16
+ };
17
+ /**
18
+ * Tenant-safe durable invitation adapter. Acceptance, the optional domain hook,
19
+ * and its audit record commit atomically under the BYPASSRLS system handle.
20
+ */
21
+ export declare function createDrizzleInviteStore(database: Pick<AppkitDb<Schema>, 'withSuperAdmin'>, options?: DrizzleInviteStoreOptions): InviteStore;
22
+ export {};
23
+ //# sourceMappingURL=drizzle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"drizzle.d.ts","sourceRoot":"","sources":["../src/drizzle.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAA;AAC/D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,4BAA4B,CAAA;AAO1D,OAAO,KAAK,EAGV,WAAW,EACZ,MAAM,WAAW,CAAA;AAElB,OAAO,KAAK,YAAY,MAAM,mCAAmC,CAAA;AAEjE,KAAK,MAAM,GAAG,OAAO,YAAY,CAAA;AACjC,KAAK,QAAQ,GAAG,cAAc,CAAC,MAAM,CAAC,CAAA;AAEtC,MAAM,MAAM,yBAAyB,GAAG;IACtC,4EAA4E;IAC5E,UAAU,CAAC,EAAE,CAAC,EAAE,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IAC9E,yEAAyE;IACzE,KAAK,CAAC,EAAE;QACN,UAAU,CAAC,EAAE,MAAM,CAAA;QACnB,MAAM,CAAC,EAAE,MAAM,CAAA;QACf,OAAO,CAAC,EAAE,MAAM,CAAA;KACjB,CAAA;CACF,CAAA;AAED;;;GAGG;AACH,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,gBAAgB,CAAC,EAClD,OAAO,GAAE,yBAA8B,GACtC,WAAW,CA8Bb"}
package/drizzle.js ADDED
@@ -0,0 +1,66 @@
1
+ import { and, eq } from 'drizzle-orm';
2
+ import { auditLog, memberships, tenants, users, } from '@braedonsaunders/appkit-db/schema';
3
+ import { evaluateInviteAccess } from './invites.js';
4
+ import * as appkitSchema from '@braedonsaunders/appkit-db/schema';
5
+ /**
6
+ * Tenant-safe durable invitation adapter. Acceptance, the optional domain hook,
7
+ * and its audit record commit atomically under the BYPASSRLS system handle.
8
+ */
9
+ export function createDrizzleInviteStore(database, options = {}) {
10
+ async function load(db, membershipId, lock) {
11
+ let query = db
12
+ .select({
13
+ membershipId: memberships.id,
14
+ tenantId: memberships.tenantId,
15
+ tenantName: tenants.name,
16
+ userId: memberships.userId,
17
+ invitedAt: memberships.invitedAt,
18
+ status: memberships.status,
19
+ emailVerified: users.emailVerified,
20
+ tenantStatus: tenants.status,
21
+ })
22
+ .from(memberships)
23
+ .innerJoin(users, eq(users.id, memberships.userId))
24
+ .innerJoin(tenants, eq(tenants.id, memberships.tenantId))
25
+ .where(eq(memberships.id, membershipId))
26
+ .limit(1);
27
+ if (lock)
28
+ query = query.for('update');
29
+ const [row] = await query;
30
+ return row ?? null;
31
+ }
32
+ return {
33
+ inspect: (membershipId) => database.withSuperAdmin((db) => load(db, membershipId, false)),
34
+ accept: (payload, sessionUserId) => database.withSuperAdmin((db) => db.transaction(async (tx) => acceptInTransaction(tx, payload, sessionUserId, options, load))),
35
+ };
36
+ }
37
+ async function acceptInTransaction(tx, payload, sessionUserId, options, load) {
38
+ const row = await load(tx, payload.membershipId, true);
39
+ const state = evaluateInviteAccess(payload, sessionUserId, row);
40
+ if (state !== 'pending' || !row)
41
+ return state;
42
+ const acceptedAt = new Date();
43
+ const [accepted] = await tx
44
+ .update(memberships)
45
+ .set({ status: 'active', joinedAt: acceptedAt, updatedAt: acceptedAt })
46
+ .where(and(eq(memberships.id, payload.membershipId), eq(memberships.tenantId, payload.tenantId), eq(memberships.userId, payload.userId), eq(memberships.status, 'invited'), eq(memberships.invitedAt, new Date(payload.invitedAt))))
47
+ .returning({ id: memberships.id });
48
+ if (!accepted) {
49
+ const current = await load(tx, payload.membershipId, false);
50
+ return evaluateInviteAccess(payload, sessionUserId, current);
51
+ }
52
+ await options.onAccepted?.(tx, payload.tenantId, payload.userId);
53
+ await tx.insert(auditLog).values({
54
+ tenantId: payload.tenantId,
55
+ actorUserId: sessionUserId,
56
+ entityType: options.audit?.entityType ?? 'tenant_user',
57
+ entityId: payload.membershipId,
58
+ action: options.audit?.action ?? 'update',
59
+ summary: options.audit?.summary ?? 'Accepted membership invitation',
60
+ before: { status: 'invited' },
61
+ after: { status: 'active', joinedAt: acceptedAt.toISOString() },
62
+ metadata: { acceptedVia: 'magic_link' },
63
+ });
64
+ return 'active';
65
+ }
66
+ //# sourceMappingURL=drizzle.js.map
package/drizzle.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"drizzle.js","sourceRoot":"","sources":["../src/drizzle.ts"],"names":[],"mappings":"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","sourcesContent":["import { and, eq } from 'drizzle-orm'\nimport type { NodePgDatabase } from 'drizzle-orm/node-postgres'\nimport type { AppkitDb } from '@braedonsaunders/appkit-db'\nimport {\n auditLog,\n memberships,\n tenants,\n users,\n} from '@braedonsaunders/appkit-db/schema'\nimport type {\n InviteGrantPayload,\n InviteRecord,\n InviteStore,\n} from './invites'\nimport { evaluateInviteAccess } from './invites'\nimport * as appkitSchema from '@braedonsaunders/appkit-db/schema'\n\ntype Schema = typeof appkitSchema\ntype Database = NodePgDatabase<Schema>\n\nexport type DrizzleInviteStoreOptions = {\n /** Optional domain hook committed in the same transaction as acceptance. */\n onAccepted?: (db: Database, tenantId: string, userId: string) => Promise<void>\n /** Customize the audit vocabulary without changing the state machine. */\n audit?: {\n entityType?: string\n action?: string\n summary?: string\n }\n}\n\n/**\n * Tenant-safe durable invitation adapter. Acceptance, the optional domain hook,\n * and its audit record commit atomically under the BYPASSRLS system handle.\n */\nexport function createDrizzleInviteStore(\n database: Pick<AppkitDb<Schema>, 'withSuperAdmin'>,\n options: DrizzleInviteStoreOptions = {},\n): InviteStore {\n async function load(db: Database, membershipId: string, lock: boolean): Promise<InviteRecord | null> {\n let query = db\n .select({\n membershipId: memberships.id,\n tenantId: memberships.tenantId,\n tenantName: tenants.name,\n userId: memberships.userId,\n invitedAt: memberships.invitedAt,\n status: memberships.status,\n emailVerified: users.emailVerified,\n tenantStatus: tenants.status,\n })\n .from(memberships)\n .innerJoin(users, eq(users.id, memberships.userId))\n .innerJoin(tenants, eq(tenants.id, memberships.tenantId))\n .where(eq(memberships.id, membershipId))\n .limit(1)\n if (lock) query = query.for('update') as typeof query\n const [row] = await query\n return row ?? null\n }\n\n return {\n inspect: (membershipId) => database.withSuperAdmin((db) => load(db, membershipId, false)),\n accept: (payload, sessionUserId) =>\n database.withSuperAdmin((db) =>\n db.transaction(async (tx) => acceptInTransaction(tx, payload, sessionUserId, options, load)),\n ),\n }\n}\n\nasync function acceptInTransaction(\n tx: Database,\n payload: InviteGrantPayload,\n sessionUserId: string,\n options: DrizzleInviteStoreOptions,\n load: (db: Database, membershipId: string, lock: boolean) => Promise<InviteRecord | null>,\n) {\n const row = await load(tx, payload.membershipId, true)\n const state = evaluateInviteAccess(payload, sessionUserId, row)\n if (state !== 'pending' || !row) return state\n\n const acceptedAt = new Date()\n const [accepted] = await tx\n .update(memberships)\n .set({ status: 'active', joinedAt: acceptedAt, updatedAt: acceptedAt })\n .where(\n and(\n eq(memberships.id, payload.membershipId),\n eq(memberships.tenantId, payload.tenantId),\n eq(memberships.userId, payload.userId),\n eq(memberships.status, 'invited'),\n eq(memberships.invitedAt, new Date(payload.invitedAt)),\n ),\n )\n .returning({ id: memberships.id })\n\n if (!accepted) {\n const current = await load(tx, payload.membershipId, false)\n return evaluateInviteAccess(payload, sessionUserId, current)\n }\n\n await options.onAccepted?.(tx, payload.tenantId, payload.userId)\n await tx.insert(auditLog).values({\n tenantId: payload.tenantId,\n actorUserId: sessionUserId,\n entityType: options.audit?.entityType ?? 'tenant_user',\n entityId: payload.membershipId,\n action: options.audit?.action ?? 'update',\n summary: options.audit?.summary ?? 'Accepted membership invitation',\n before: { status: 'invited' },\n after: { status: 'active', joinedAt: acceptedAt.toISOString() },\n metadata: { acceptedVia: 'magic_link' },\n })\n return 'active' as const\n}\n"]}
package/index.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { createAppkitAuth, createLazyAuth, type AppkitAuth, type AppkitAuthOptions } from './server.js';
2
+ export { createInviteService, evaluateInviteAccess, nextInviteGenerationDate, type InviteAccessState, type InviteGrantInput, type InviteGrantPayload, type InviteGrantVerification, type InviteInspection, type InviteRecord, type InviteService, type InviteServiceOptions, type InviteStore, } from './invites.js';
3
+ export type { AuthEmail, AuthEmailKind, AuthEmailRenderer, AuthEmailSender } from './mail.js';
4
+ //# sourceMappingURL=index.d.ts.map
package/index.d.ts.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,KAAK,UAAU,EAAE,KAAK,iBAAiB,EAAE,MAAM,UAAU,CAAA;AACpG,OAAO,EACL,mBAAmB,EACnB,oBAAoB,EACpB,wBAAwB,EACxB,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,EACvB,KAAK,uBAAuB,EAC5B,KAAK,gBAAgB,EACrB,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,oBAAoB,EACzB,KAAK,WAAW,GACjB,MAAM,WAAW,CAAA;AAClB,YAAY,EAAE,SAAS,EAAE,aAAa,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,QAAQ,CAAA"}
package/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { createAppkitAuth, createLazyAuth } from './server.js';
2
+ export { createInviteService, evaluateInviteAccess, nextInviteGenerationDate, } from './invites.js';
3
+ //# sourceMappingURL=index.js.map
package/index.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAA2C,MAAM,UAAU,CAAA;AACpG,OAAO,EACL,mBAAmB,EACnB,oBAAoB,EACpB,wBAAwB,GAUzB,MAAM,WAAW,CAAA","sourcesContent":["export { createAppkitAuth, createLazyAuth, type AppkitAuth, type AppkitAuthOptions } from './server'\nexport {\n createInviteService,\n evaluateInviteAccess,\n nextInviteGenerationDate,\n type InviteAccessState,\n type InviteGrantInput,\n type InviteGrantPayload,\n type InviteGrantVerification,\n type InviteInspection,\n type InviteRecord,\n type InviteService,\n type InviteServiceOptions,\n type InviteStore,\n} from './invites'\nexport type { AuthEmail, AuthEmailKind, AuthEmailRenderer, AuthEmailSender } from './mail'\n"]}
package/invites.d.ts ADDED
@@ -0,0 +1,79 @@
1
+ declare const INVITE_GRANT_VERSION = 1;
2
+ export type InviteGrantInput = {
3
+ membershipId: string;
4
+ tenantId: string;
5
+ userId: string;
6
+ invitedAt: Date;
7
+ };
8
+ export type InviteGrantPayload = {
9
+ v: typeof INVITE_GRANT_VERSION;
10
+ membershipId: string;
11
+ tenantId: string;
12
+ userId: string;
13
+ invitedAt: number;
14
+ issuedAt: number;
15
+ expiresAt: number;
16
+ };
17
+ export type InviteGrantVerification = {
18
+ ok: true;
19
+ payload: InviteGrantPayload;
20
+ } | {
21
+ ok: false;
22
+ reason: 'invalid' | 'expired';
23
+ };
24
+ export type InviteAccessState = 'active' | 'pending' | 'suspended' | 'tenant_unavailable' | 'unverified' | 'invalid' | 'expired';
25
+ export type InviteRecord = {
26
+ membershipId: string;
27
+ tenantId: string;
28
+ tenantName: string;
29
+ userId: string;
30
+ invitedAt: Date | null;
31
+ status: 'active' | 'invited' | 'suspended';
32
+ emailVerified: boolean;
33
+ tenantStatus: 'active' | 'suspended' | 'archived';
34
+ };
35
+ export type InviteInspection = {
36
+ state: InviteAccessState;
37
+ tenantId: string | null;
38
+ tenantName: string | null;
39
+ };
40
+ export type InviteStore = {
41
+ /** Read-only inspection. This must never activate a membership. */
42
+ inspect: (membershipId: string) => Promise<InviteRecord | null>;
43
+ /**
44
+ * Atomically lock, re-evaluate, activate, and audit a pending membership.
45
+ * The store receives the already signature-verified grant and must return the
46
+ * final state so concurrent or superseded links cannot double-accept.
47
+ */
48
+ accept: (payload: InviteGrantPayload, sessionUserId: string) => Promise<InviteAccessState>;
49
+ };
50
+ export type InviteServiceOptions = {
51
+ secret: string;
52
+ store: InviteStore;
53
+ ttlSeconds?: number;
54
+ /** HKDF domain separation. Change this only when intentionally rotating formats. */
55
+ signingContext?: string;
56
+ callbackPath?: string;
57
+ };
58
+ export type InviteService = {
59
+ ttlSeconds: number;
60
+ createInviteGrant: (input: InviteGrantInput, now?: number) => string;
61
+ verifyInviteGrant: (raw: string, now?: number) => InviteGrantVerification;
62
+ inviteCallbackPath: (grant: string) => string;
63
+ inviteGrantFromCallbackURL: (callbackURL: unknown, baseURL: string) => string | null;
64
+ acceptInviteAfterMagicLink: (rawGrant: string, sessionUserId: string) => Promise<InviteAccessState>;
65
+ inspectInviteForUser: (rawGrant: string, sessionUserId: string) => Promise<InviteInspection>;
66
+ };
67
+ /**
68
+ * The complete invitation grant flow extracted from the production runtime.
69
+ * Better Auth's one-time magic-link token proves mailbox possession; this
70
+ * independently signed grant binds that verified callback to exactly one
71
+ * pending membership generation.
72
+ */
73
+ export declare function createInviteService(options: InviteServiceOptions): InviteService;
74
+ /** Pure state machine shared by memory and durable stores. */
75
+ export declare function evaluateInviteAccess(payload: InviteGrantPayload, sessionUserId: string, record: InviteRecord | null): InviteAccessState;
76
+ /** Every resend advances the generation, even within the same clock tick. */
77
+ export declare function nextInviteGenerationDate(previous: Date | null, now?: Date): Date;
78
+ export {};
79
+ //# sourceMappingURL=invites.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"invites.d.ts","sourceRoot":"","sources":["../src/invites.ts"],"names":[],"mappings":"AAEA,QAAA,MAAM,oBAAoB,IAAI,CAAA;AAI9B,MAAM,MAAM,gBAAgB,GAAG;IAC7B,YAAY,EAAE,MAAM,CAAA;IACpB,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;IACd,SAAS,EAAE,IAAI,CAAA;CAChB,CAAA;AAED,MAAM,MAAM,kBAAkB,GAAG;IAC/B,CAAC,EAAE,OAAO,oBAAoB,CAAA;IAC9B,YAAY,EAAE,MAAM,CAAA;IACpB,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;IACd,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;CAClB,CAAA;AAED,MAAM,MAAM,uBAAuB,GAC/B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,kBAAkB,CAAA;CAAE,GACzC;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,SAAS,GAAG,SAAS,CAAA;CAAE,CAAA;AAEhD,MAAM,MAAM,iBAAiB,GACzB,QAAQ,GACR,SAAS,GACT,WAAW,GACX,oBAAoB,GACpB,YAAY,GACZ,SAAS,GACT,SAAS,CAAA;AAEb,MAAM,MAAM,YAAY,GAAG;IACzB,YAAY,EAAE,MAAM,CAAA;IACpB,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,MAAM,CAAA;IACd,SAAS,EAAE,IAAI,GAAG,IAAI,CAAA;IACtB,MAAM,EAAE,QAAQ,GAAG,SAAS,GAAG,WAAW,CAAA;IAC1C,aAAa,EAAE,OAAO,CAAA;IACtB,YAAY,EAAE,QAAQ,GAAG,WAAW,GAAG,UAAU,CAAA;CAClD,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,KAAK,EAAE,iBAAiB,CAAA;IACxB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CAC1B,CAAA;AAED,MAAM,MAAM,WAAW,GAAG;IACxB,mEAAmE;IACnE,OAAO,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAA;IAC/D;;;;OAIG;IACH,MAAM,EAAE,CAAC,OAAO,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,iBAAiB,CAAC,CAAA;CAC3F,CAAA;AAED,MAAM,MAAM,oBAAoB,GAAG;IACjC,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,WAAW,CAAA;IAClB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,oFAAoF;IACpF,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB,CAAA;AAED,MAAM,MAAM,aAAa,GAAG;IAC1B,UAAU,EAAE,MAAM,CAAA;IAClB,iBAAiB,EAAE,CAAC,KAAK,EAAE,gBAAgB,EAAE,GAAG,CAAC,EAAE,MAAM,KAAK,MAAM,CAAA;IACpE,iBAAiB,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,KAAK,uBAAuB,CAAA;IACzE,kBAAkB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAA;IAC7C,0BAA0B,EAAE,CAAC,WAAW,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAA;IACpF,0BAA0B,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,iBAAiB,CAAC,CAAA;IACnG,oBAAoB,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,gBAAgB,CAAC,CAAA;CAC7F,CAAA;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,aAAa,CA4HhF;AAED,8DAA8D;AAC9D,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,kBAAkB,EAC3B,aAAa,EAAE,MAAM,EACrB,MAAM,EAAE,YAAY,GAAG,IAAI,GAC1B,iBAAiB,CAenB;AAED,6EAA6E;AAC7E,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,IAAI,GAAG,IAAI,EAAE,GAAG,OAAa,GAAG,IAAI,CAOtF"}
package/invites.js ADDED
@@ -0,0 +1,185 @@
1
+ import { createHmac, hkdfSync, timingSafeEqual } from 'node:crypto';
2
+ const INVITE_GRANT_VERSION = 1;
3
+ const DEFAULT_INVITE_TTL_SECONDS = 15 * 60;
4
+ const MAX_GRANT_LENGTH = 4096;
5
+ /**
6
+ * The complete invitation grant flow extracted from the production runtime.
7
+ * Better Auth's one-time magic-link token proves mailbox possession; this
8
+ * independently signed grant binds that verified callback to exactly one
9
+ * pending membership generation.
10
+ */
11
+ export function createInviteService(options) {
12
+ if (options.secret.length < 32)
13
+ throw new Error('Invitation signing secret must contain at least 32 characters.');
14
+ const ttlSeconds = options.ttlSeconds ?? DEFAULT_INVITE_TTL_SECONDS;
15
+ if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds < 60) {
16
+ throw new Error('Invitation TTL must be a whole number of at least 60 seconds.');
17
+ }
18
+ const callbackPath = normalizeCallbackPath(options.callbackPath ?? '/invite/accept');
19
+ const signingKey = Buffer.from(hkdfSync('sha256', Buffer.from(options.secret), Buffer.alloc(0), Buffer.from(options.signingContext ?? 'appkit.invite.v1'), 32));
20
+ const sign = (payload) => createHmac('sha256', signingKey).update(payload).digest();
21
+ function createInviteGrant(input, now = Date.now()) {
22
+ if (!Number.isSafeInteger(now) || !Number.isSafeInteger(input.invitedAt.getTime())) {
23
+ throw new Error('Invalid invitation issuance time.');
24
+ }
25
+ for (const [name, value] of Object.entries({
26
+ membershipId: input.membershipId,
27
+ tenantId: input.tenantId,
28
+ userId: input.userId,
29
+ })) {
30
+ if (!isBoundedString(value, name === 'userId' ? 200 : 100)) {
31
+ throw new Error(`Invalid ${name}.`);
32
+ }
33
+ }
34
+ const payload = {
35
+ v: INVITE_GRANT_VERSION,
36
+ membershipId: input.membershipId,
37
+ tenantId: input.tenantId,
38
+ userId: input.userId,
39
+ invitedAt: input.invitedAt.getTime(),
40
+ issuedAt: now,
41
+ expiresAt: now + ttlSeconds * 1000,
42
+ };
43
+ const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url');
44
+ return `${encoded}.${sign(encoded).toString('base64url')}`;
45
+ }
46
+ function verifyInviteGrant(raw, now = Date.now()) {
47
+ if (!raw || raw.length > MAX_GRANT_LENGTH || !Number.isSafeInteger(now)) {
48
+ return { ok: false, reason: 'invalid' };
49
+ }
50
+ const separator = raw.lastIndexOf('.');
51
+ if (separator <= 0 || separator === raw.length - 1)
52
+ return { ok: false, reason: 'invalid' };
53
+ const encoded = raw.slice(0, separator);
54
+ const signature = raw.slice(separator + 1);
55
+ let expected;
56
+ let provided;
57
+ try {
58
+ expected = sign(encoded);
59
+ provided = Buffer.from(signature, 'base64url');
60
+ }
61
+ catch {
62
+ return { ok: false, reason: 'invalid' };
63
+ }
64
+ if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
65
+ return { ok: false, reason: 'invalid' };
66
+ }
67
+ let payload = null;
68
+ try {
69
+ payload = parsePayload(JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')), ttlSeconds);
70
+ }
71
+ catch {
72
+ return { ok: false, reason: 'invalid' };
73
+ }
74
+ if (!payload)
75
+ return { ok: false, reason: 'invalid' };
76
+ if (now < payload.issuedAt - 60_000 || now > payload.expiresAt) {
77
+ return { ok: false, reason: 'expired' };
78
+ }
79
+ return { ok: true, payload };
80
+ }
81
+ function inviteCallbackPath(grant) {
82
+ return `${callbackPath}?${new URLSearchParams({ grant }).toString()}`;
83
+ }
84
+ function inviteGrantFromCallbackURL(callbackURL, baseURL) {
85
+ if (typeof callbackURL !== 'string' || !callbackURL)
86
+ return null;
87
+ try {
88
+ const base = new URL(baseURL);
89
+ const callback = new URL(callbackURL, base);
90
+ if (callback.origin !== base.origin || callback.pathname !== callbackPath)
91
+ return null;
92
+ return callback.searchParams.get('grant');
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
98
+ async function acceptInviteAfterMagicLink(rawGrant, sessionUserId) {
99
+ const verified = verifyInviteGrant(rawGrant);
100
+ if (!verified.ok)
101
+ return verified.reason;
102
+ return options.store.accept(verified.payload, sessionUserId);
103
+ }
104
+ async function inspectInviteForUser(rawGrant, sessionUserId) {
105
+ const verified = verifyInviteGrant(rawGrant);
106
+ if (!verified.ok)
107
+ return { state: verified.reason, tenantId: null, tenantName: null };
108
+ const record = await options.store.inspect(verified.payload.membershipId);
109
+ return {
110
+ state: evaluateInviteAccess(verified.payload, sessionUserId, record),
111
+ tenantId: record?.tenantId ?? null,
112
+ tenantName: record?.tenantName ?? null,
113
+ };
114
+ }
115
+ return {
116
+ ttlSeconds,
117
+ createInviteGrant,
118
+ verifyInviteGrant,
119
+ inviteCallbackPath,
120
+ inviteGrantFromCallbackURL,
121
+ acceptInviteAfterMagicLink,
122
+ inspectInviteForUser,
123
+ };
124
+ }
125
+ /** Pure state machine shared by memory and durable stores. */
126
+ export function evaluateInviteAccess(payload, sessionUserId, record) {
127
+ if (!record || sessionUserId !== payload.userId)
128
+ return 'invalid';
129
+ if (record.membershipId !== payload.membershipId ||
130
+ record.tenantId !== payload.tenantId ||
131
+ record.userId !== payload.userId ||
132
+ record.invitedAt?.getTime() !== payload.invitedAt) {
133
+ return 'invalid';
134
+ }
135
+ if (record.tenantStatus !== 'active')
136
+ return 'tenant_unavailable';
137
+ if (record.status === 'suspended')
138
+ return 'suspended';
139
+ if (record.status === 'active')
140
+ return 'active';
141
+ if (!record.emailVerified)
142
+ return 'unverified';
143
+ return 'pending';
144
+ }
145
+ /** Every resend advances the generation, even within the same clock tick. */
146
+ export function nextInviteGenerationDate(previous, now = new Date()) {
147
+ const nowMs = now.getTime();
148
+ const previousMs = previous?.getTime() ?? -1;
149
+ if (!Number.isSafeInteger(nowMs) || !Number.isSafeInteger(previousMs)) {
150
+ throw new Error('Invalid invitation generation time.');
151
+ }
152
+ return new Date(Math.max(nowMs, previousMs + 1));
153
+ }
154
+ function parsePayload(value, ttlSeconds) {
155
+ if (!value || typeof value !== 'object' || Array.isArray(value))
156
+ return null;
157
+ const payload = value;
158
+ if (payload.v !== INVITE_GRANT_VERSION ||
159
+ !isBoundedString(payload.membershipId, 100) ||
160
+ !isBoundedString(payload.tenantId, 100) ||
161
+ !isBoundedString(payload.userId, 200) ||
162
+ !Number.isSafeInteger(payload.invitedAt) ||
163
+ !Number.isSafeInteger(payload.issuedAt) ||
164
+ !Number.isSafeInteger(payload.expiresAt) ||
165
+ payload.expiresAt <= payload.issuedAt ||
166
+ payload.expiresAt - payload.issuedAt > ttlSeconds * 1000) {
167
+ return null;
168
+ }
169
+ return payload;
170
+ }
171
+ function isBoundedString(value, max) {
172
+ return typeof value === 'string' && value.length > 0 && value.length <= max;
173
+ }
174
+ function normalizeCallbackPath(value) {
175
+ try {
176
+ const url = new URL(value, 'https://app.invalid');
177
+ if (url.origin !== 'https://app.invalid' || !url.pathname.startsWith('/'))
178
+ throw new Error();
179
+ return url.pathname;
180
+ }
181
+ catch {
182
+ throw new Error('Invitation callback path must be an absolute same-origin path.');
183
+ }
184
+ }
185
+ //# sourceMappingURL=invites.js.map
package/invites.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"invites.js","sourceRoot":"","sources":["../src/invites.ts"],"names":[],"mappings":"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","sourcesContent":["import { createHmac, hkdfSync, timingSafeEqual } from 'node:crypto'\n\nconst INVITE_GRANT_VERSION = 1\nconst DEFAULT_INVITE_TTL_SECONDS = 15 * 60\nconst MAX_GRANT_LENGTH = 4096\n\nexport type InviteGrantInput = {\n membershipId: string\n tenantId: string\n userId: string\n invitedAt: Date\n}\n\nexport type InviteGrantPayload = {\n v: typeof INVITE_GRANT_VERSION\n membershipId: string\n tenantId: string\n userId: string\n invitedAt: number\n issuedAt: number\n expiresAt: number\n}\n\nexport type InviteGrantVerification =\n | { ok: true; payload: InviteGrantPayload }\n | { ok: false; reason: 'invalid' | 'expired' }\n\nexport type InviteAccessState =\n | 'active'\n | 'pending'\n | 'suspended'\n | 'tenant_unavailable'\n | 'unverified'\n | 'invalid'\n | 'expired'\n\nexport type InviteRecord = {\n membershipId: string\n tenantId: string\n tenantName: string\n userId: string\n invitedAt: Date | null\n status: 'active' | 'invited' | 'suspended'\n emailVerified: boolean\n tenantStatus: 'active' | 'suspended' | 'archived'\n}\n\nexport type InviteInspection = {\n state: InviteAccessState\n tenantId: string | null\n tenantName: string | null\n}\n\nexport type InviteStore = {\n /** Read-only inspection. This must never activate a membership. */\n inspect: (membershipId: string) => Promise<InviteRecord | null>\n /**\n * Atomically lock, re-evaluate, activate, and audit a pending membership.\n * The store receives the already signature-verified grant and must return the\n * final state so concurrent or superseded links cannot double-accept.\n */\n accept: (payload: InviteGrantPayload, sessionUserId: string) => Promise<InviteAccessState>\n}\n\nexport type InviteServiceOptions = {\n secret: string\n store: InviteStore\n ttlSeconds?: number\n /** HKDF domain separation. Change this only when intentionally rotating formats. */\n signingContext?: string\n callbackPath?: string\n}\n\nexport type InviteService = {\n ttlSeconds: number\n createInviteGrant: (input: InviteGrantInput, now?: number) => string\n verifyInviteGrant: (raw: string, now?: number) => InviteGrantVerification\n inviteCallbackPath: (grant: string) => string\n inviteGrantFromCallbackURL: (callbackURL: unknown, baseURL: string) => string | null\n acceptInviteAfterMagicLink: (rawGrant: string, sessionUserId: string) => Promise<InviteAccessState>\n inspectInviteForUser: (rawGrant: string, sessionUserId: string) => Promise<InviteInspection>\n}\n\n/**\n * The complete invitation grant flow extracted from the production runtime.\n * Better Auth's one-time magic-link token proves mailbox possession; this\n * independently signed grant binds that verified callback to exactly one\n * pending membership generation.\n */\nexport function createInviteService(options: InviteServiceOptions): InviteService {\n if (options.secret.length < 32) throw new Error('Invitation signing secret must contain at least 32 characters.')\n const ttlSeconds = options.ttlSeconds ?? DEFAULT_INVITE_TTL_SECONDS\n if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds < 60) {\n throw new Error('Invitation TTL must be a whole number of at least 60 seconds.')\n }\n const callbackPath = normalizeCallbackPath(options.callbackPath ?? '/invite/accept')\n const signingKey = Buffer.from(\n hkdfSync(\n 'sha256',\n Buffer.from(options.secret),\n Buffer.alloc(0),\n Buffer.from(options.signingContext ?? 'appkit.invite.v1'),\n 32,\n ),\n )\n const sign = (payload: string) => createHmac('sha256', signingKey).update(payload).digest()\n\n function createInviteGrant(input: InviteGrantInput, now = Date.now()): string {\n if (!Number.isSafeInteger(now) || !Number.isSafeInteger(input.invitedAt.getTime())) {\n throw new Error('Invalid invitation issuance time.')\n }\n for (const [name, value] of Object.entries({\n membershipId: input.membershipId,\n tenantId: input.tenantId,\n userId: input.userId,\n })) {\n if (!isBoundedString(value, name === 'userId' ? 200 : 100)) {\n throw new Error(`Invalid ${name}.`)\n }\n }\n const payload: InviteGrantPayload = {\n v: INVITE_GRANT_VERSION,\n membershipId: input.membershipId,\n tenantId: input.tenantId,\n userId: input.userId,\n invitedAt: input.invitedAt.getTime(),\n issuedAt: now,\n expiresAt: now + ttlSeconds * 1000,\n }\n const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url')\n return `${encoded}.${sign(encoded).toString('base64url')}`\n }\n\n function verifyInviteGrant(raw: string, now = Date.now()): InviteGrantVerification {\n if (!raw || raw.length > MAX_GRANT_LENGTH || !Number.isSafeInteger(now)) {\n return { ok: false, reason: 'invalid' }\n }\n const separator = raw.lastIndexOf('.')\n if (separator <= 0 || separator === raw.length - 1) return { ok: false, reason: 'invalid' }\n const encoded = raw.slice(0, separator)\n const signature = raw.slice(separator + 1)\n let expected: Buffer\n let provided: Buffer\n try {\n expected = sign(encoded)\n provided = Buffer.from(signature, 'base64url')\n } catch {\n return { ok: false, reason: 'invalid' }\n }\n if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {\n return { ok: false, reason: 'invalid' }\n }\n let payload: InviteGrantPayload | null = null\n try {\n payload = parsePayload(JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')), ttlSeconds)\n } catch {\n return { ok: false, reason: 'invalid' }\n }\n if (!payload) return { ok: false, reason: 'invalid' }\n if (now < payload.issuedAt - 60_000 || now > payload.expiresAt) {\n return { ok: false, reason: 'expired' }\n }\n return { ok: true, payload }\n }\n\n function inviteCallbackPath(grant: string): string {\n return `${callbackPath}?${new URLSearchParams({ grant }).toString()}`\n }\n\n function inviteGrantFromCallbackURL(callbackURL: unknown, baseURL: string): string | null {\n if (typeof callbackURL !== 'string' || !callbackURL) return null\n try {\n const base = new URL(baseURL)\n const callback = new URL(callbackURL, base)\n if (callback.origin !== base.origin || callback.pathname !== callbackPath) return null\n return callback.searchParams.get('grant')\n } catch {\n return null\n }\n }\n\n async function acceptInviteAfterMagicLink(\n rawGrant: string,\n sessionUserId: string,\n ): Promise<InviteAccessState> {\n const verified = verifyInviteGrant(rawGrant)\n if (!verified.ok) return verified.reason\n return options.store.accept(verified.payload, sessionUserId)\n }\n\n async function inspectInviteForUser(\n rawGrant: string,\n sessionUserId: string,\n ): Promise<InviteInspection> {\n const verified = verifyInviteGrant(rawGrant)\n if (!verified.ok) return { state: verified.reason, tenantId: null, tenantName: null }\n const record = await options.store.inspect(verified.payload.membershipId)\n return {\n state: evaluateInviteAccess(verified.payload, sessionUserId, record),\n tenantId: record?.tenantId ?? null,\n tenantName: record?.tenantName ?? null,\n }\n }\n\n return {\n ttlSeconds,\n createInviteGrant,\n verifyInviteGrant,\n inviteCallbackPath,\n inviteGrantFromCallbackURL,\n acceptInviteAfterMagicLink,\n inspectInviteForUser,\n }\n}\n\n/** Pure state machine shared by memory and durable stores. */\nexport function evaluateInviteAccess(\n payload: InviteGrantPayload,\n sessionUserId: string,\n record: InviteRecord | null,\n): InviteAccessState {\n if (!record || sessionUserId !== payload.userId) return 'invalid'\n if (\n record.membershipId !== payload.membershipId ||\n record.tenantId !== payload.tenantId ||\n record.userId !== payload.userId ||\n record.invitedAt?.getTime() !== payload.invitedAt\n ) {\n return 'invalid'\n }\n if (record.tenantStatus !== 'active') return 'tenant_unavailable'\n if (record.status === 'suspended') return 'suspended'\n if (record.status === 'active') return 'active'\n if (!record.emailVerified) return 'unverified'\n return 'pending'\n}\n\n/** Every resend advances the generation, even within the same clock tick. */\nexport function nextInviteGenerationDate(previous: Date | null, now = new Date()): Date {\n const nowMs = now.getTime()\n const previousMs = previous?.getTime() ?? -1\n if (!Number.isSafeInteger(nowMs) || !Number.isSafeInteger(previousMs)) {\n throw new Error('Invalid invitation generation time.')\n }\n return new Date(Math.max(nowMs, previousMs + 1))\n}\n\nfunction parsePayload(value: unknown, ttlSeconds: number): InviteGrantPayload | null {\n if (!value || typeof value !== 'object' || Array.isArray(value)) return null\n const payload = value as Partial<InviteGrantPayload>\n if (\n payload.v !== INVITE_GRANT_VERSION ||\n !isBoundedString(payload.membershipId, 100) ||\n !isBoundedString(payload.tenantId, 100) ||\n !isBoundedString(payload.userId, 200) ||\n !Number.isSafeInteger(payload.invitedAt) ||\n !Number.isSafeInteger(payload.issuedAt) ||\n !Number.isSafeInteger(payload.expiresAt) ||\n payload.expiresAt! <= payload.issuedAt! ||\n payload.expiresAt! - payload.issuedAt! > ttlSeconds * 1000\n ) {\n return null\n }\n return payload as InviteGrantPayload\n}\n\nfunction isBoundedString(value: unknown, max: number): value is string {\n return typeof value === 'string' && value.length > 0 && value.length <= max\n}\n\nfunction normalizeCallbackPath(value: string): string {\n try {\n const url = new URL(value, 'https://app.invalid')\n if (url.origin !== 'https://app.invalid' || !url.pathname.startsWith('/')) throw new Error()\n return url.pathname\n } catch {\n throw new Error('Invitation callback path must be an absolute same-origin path.')\n }\n}\n"]}