@voyant-travel/auth 0.135.1 → 0.136.0

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.
@@ -11,10 +11,40 @@ export interface AdminPrincipal extends PrincipalBase {
11
11
  actor: "staff";
12
12
  scopes: readonly string[];
13
13
  }
14
- export interface CustomerPrincipal extends PrincipalBase {
14
+ interface CustomerPrincipalBase extends PrincipalBase {
15
15
  realm: "customer";
16
16
  actor: "customer";
17
+ buyerAccountId: string;
17
18
  }
19
+ export interface CustomerIdentityPrincipal extends PrincipalBase {
20
+ realm: "customer";
21
+ actor: "customer";
22
+ buyerAccountId: null;
23
+ buyerAccountKind: null;
24
+ authOrganizationId: null;
25
+ relationshipOrganizationId: null;
26
+ relationshipPersonId: string | null;
27
+ membershipId: null;
28
+ membershipRole: null;
29
+ }
30
+ export interface PersonalCustomerPrincipal extends CustomerPrincipalBase {
31
+ buyerAccountKind: "personal";
32
+ authOrganizationId: null;
33
+ relationshipOrganizationId: null;
34
+ relationshipPersonId: string | null;
35
+ membershipId: null;
36
+ membershipRole: null;
37
+ }
38
+ export interface BusinessCustomerPrincipal extends CustomerPrincipalBase {
39
+ buyerAccountKind: "business";
40
+ authOrganizationId: string;
41
+ relationshipOrganizationId: string;
42
+ /** Identity Person remains independent from the selected business buyer. */
43
+ relationshipPersonId: string | null;
44
+ membershipId: string;
45
+ membershipRole: string;
46
+ }
47
+ export type CustomerPrincipal = CustomerIdentityPrincipal | PersonalCustomerPrincipal | BusinessCustomerPrincipal;
18
48
  export type AuthPrincipal = AdminPrincipal | CustomerPrincipal;
19
49
  /** Complete session replacement seam for advanced self-host integrations. */
20
50
  export interface AuthRealmSessionPort<Context = unknown> {
@@ -1 +1 @@
1
- {"version":3,"file":"auth-realms.js","sourceRoot":"","sources":["../src/auth-realms.ts"],"names":[],"mappings":"AAsDA,MAAM,UAAU,oCAAoC,CAClD,eAAwD,EACxD,gBAAmD;IAEnD,OAAO;QACL,EAAE,EAAE,eAAe,CAAC,EAAE;QACtB,KAAK,CAAC,YAAY,CAAC,KAAY,EAAE,OAAgB;YAC/C,MAAM,QAAQ,GAAG,MAAM,eAAe,CAAC,OAAO,CAAC,KAAK,EAAE,OAAO,CAAC,CAAA;YAC9D,IAAI,eAAe,CAAC,UAAU,IAAI,CAAC,CAAC,MAAM,eAAe,CAAC,UAAU,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC,EAAE,CAAC;gBACzF,OAAO,QAAQ,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,sCAAsC,EAAE,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAA;YAC1F,CAAC;YACD,OAAO,gBAAgB,CAAC,aAAa,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAA;QAC1D,CAAC;KACF,CAAA;AACH,CAAC","sourcesContent":["/** Provider-neutral identities crossing the auth/runtime boundary. */\nexport type AuthRealm = \"admin\" | \"customer\"\n\ninterface PrincipalBase {\n realm: AuthRealm\n userId: string\n sessionId: string\n email: string | null\n}\n\nexport interface AdminPrincipal extends PrincipalBase {\n realm: \"admin\"\n actor: \"staff\"\n scopes: readonly string[]\n}\n\nexport interface CustomerPrincipal extends PrincipalBase {\n realm: \"customer\"\n actor: \"customer\"\n}\n\nexport type AuthPrincipal = AdminPrincipal | CustomerPrincipal\n\n/** Complete session replacement seam for advanced self-host integrations. */\nexport interface AuthRealmSessionPort<Context = unknown> {\n readonly realm: AuthRealm\n resolve(request: Request, context: Context): Promise<AuthPrincipal | null>\n signOut(request: Request, context: Context): Promise<Response>\n revokeUserSessions(userId: string, context: Context): Promise<void>\n}\n\nexport interface ExternalIdentity {\n providerId: string\n providerAccountId: string\n email: string | null\n emailVerified: boolean\n name: string | null\n image: string | null\n}\n\n/**\n * Preferred external-IdP seam. Auth0, Clerk, WorkOS, or generic OIDC adapters\n * verify their assertion here; Better Auth remains the local session issuer.\n */\nexport interface ExternalIdentityAdapter<Input, Context = unknown> {\n readonly id: string\n resolve(input: Input, context: Context): Promise<ExternalIdentity>\n revalidate?(identity: ExternalIdentity, context: Context): Promise<boolean>\n}\n\nexport interface BetterAuthIdentityBridge<Context = unknown> {\n createSession(identity: ExternalIdentity, context: Context): Promise<Response>\n}\n\nexport function createExternalIdentitySessionAdapter<Input, Context = unknown>(\n identityAdapter: ExternalIdentityAdapter<Input, Context>,\n betterAuthBridge: BetterAuthIdentityBridge<Context>,\n) {\n return {\n id: identityAdapter.id,\n async authenticate(input: Input, context: Context): Promise<Response> {\n const identity = await identityAdapter.resolve(input, context)\n if (identityAdapter.revalidate && !(await identityAdapter.revalidate(identity, context))) {\n return Response.json({ error: \"External identity access was revoked\" }, { status: 403 })\n }\n return betterAuthBridge.createSession(identity, context)\n },\n }\n}\n"]}
1
+ {"version":3,"file":"auth-realms.js","sourceRoot":"","sources":["../src/auth-realms.ts"],"names":[],"mappings":"AA2FA,MAAM,UAAU,oCAAoC,CAClD,eAAwD,EACxD,gBAAmD;IAEnD,OAAO;QACL,EAAE,EAAE,eAAe,CAAC,EAAE;QACtB,KAAK,CAAC,YAAY,CAAC,KAAY,EAAE,OAAgB;YAC/C,MAAM,QAAQ,GAAG,MAAM,eAAe,CAAC,OAAO,CAAC,KAAK,EAAE,OAAO,CAAC,CAAA;YAC9D,IAAI,eAAe,CAAC,UAAU,IAAI,CAAC,CAAC,MAAM,eAAe,CAAC,UAAU,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC,EAAE,CAAC;gBACzF,OAAO,QAAQ,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,sCAAsC,EAAE,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAA;YAC1F,CAAC;YACD,OAAO,gBAAgB,CAAC,aAAa,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAA;QAC1D,CAAC;KACF,CAAA;AACH,CAAC","sourcesContent":["/** Provider-neutral identities crossing the auth/runtime boundary. */\nexport type AuthRealm = \"admin\" | \"customer\"\n\ninterface PrincipalBase {\n realm: AuthRealm\n userId: string\n sessionId: string\n email: string | null\n}\n\nexport interface AdminPrincipal extends PrincipalBase {\n realm: \"admin\"\n actor: \"staff\"\n scopes: readonly string[]\n}\n\ninterface CustomerPrincipalBase extends PrincipalBase {\n realm: \"customer\"\n actor: \"customer\"\n buyerAccountId: string\n}\n\nexport interface CustomerIdentityPrincipal extends PrincipalBase {\n realm: \"customer\"\n actor: \"customer\"\n buyerAccountId: null\n buyerAccountKind: null\n authOrganizationId: null\n relationshipOrganizationId: null\n relationshipPersonId: string | null\n membershipId: null\n membershipRole: null\n}\n\nexport interface PersonalCustomerPrincipal extends CustomerPrincipalBase {\n buyerAccountKind: \"personal\"\n authOrganizationId: null\n relationshipOrganizationId: null\n relationshipPersonId: string | null\n membershipId: null\n membershipRole: null\n}\n\nexport interface BusinessCustomerPrincipal extends CustomerPrincipalBase {\n buyerAccountKind: \"business\"\n authOrganizationId: string\n relationshipOrganizationId: string\n /** Identity Person remains independent from the selected business buyer. */\n relationshipPersonId: string | null\n membershipId: string\n membershipRole: string\n}\n\nexport type CustomerPrincipal =\n | CustomerIdentityPrincipal\n | PersonalCustomerPrincipal\n | BusinessCustomerPrincipal\n\nexport type AuthPrincipal = AdminPrincipal | CustomerPrincipal\n\n/** Complete session replacement seam for advanced self-host integrations. */\nexport interface AuthRealmSessionPort<Context = unknown> {\n readonly realm: AuthRealm\n resolve(request: Request, context: Context): Promise<AuthPrincipal | null>\n signOut(request: Request, context: Context): Promise<Response>\n revokeUserSessions(userId: string, context: Context): Promise<void>\n}\n\nexport interface ExternalIdentity {\n providerId: string\n providerAccountId: string\n email: string | null\n emailVerified: boolean\n name: string | null\n image: string | null\n}\n\n/**\n * Preferred external-IdP seam. Auth0, Clerk, WorkOS, or generic OIDC adapters\n * verify their assertion here; Better Auth remains the local session issuer.\n */\nexport interface ExternalIdentityAdapter<Input, Context = unknown> {\n readonly id: string\n resolve(input: Input, context: Context): Promise<ExternalIdentity>\n revalidate?(identity: ExternalIdentity, context: Context): Promise<boolean>\n}\n\nexport interface BetterAuthIdentityBridge<Context = unknown> {\n createSession(identity: ExternalIdentity, context: Context): Promise<Response>\n}\n\nexport function createExternalIdentitySessionAdapter<Input, Context = unknown>(\n identityAdapter: ExternalIdentityAdapter<Input, Context>,\n betterAuthBridge: BetterAuthIdentityBridge<Context>,\n) {\n return {\n id: identityAdapter.id,\n async authenticate(input: Input, context: Context): Promise<Response> {\n const identity = await identityAdapter.resolve(input, context)\n if (identityAdapter.revalidate && !(await identityAdapter.revalidate(identity, context))) {\n return Response.json({ error: \"External identity access was revoked\" }, { status: 403 })\n }\n return betterAuthBridge.createSession(identity, context)\n },\n }\n}\n"]}
@@ -0,0 +1,134 @@
1
+ import type { VoyantDb } from "@voyant-travel/hono";
2
+ import { z } from "zod";
3
+ export declare const customerBuyerAccountKinds: readonly ["personal", "business"];
4
+ export type CustomerBuyerAccountKind = (typeof customerBuyerAccountKinds)[number];
5
+ export type CustomerPersonalSignup = "open" | "disabled";
6
+ export type CustomerBusinessOnboarding = "disabled" | "open" | "request" | "invite-only";
7
+ export interface CustomerBuyerAccountPolicy {
8
+ allowedKinds: readonly CustomerBuyerAccountKind[];
9
+ personalSignup: CustomerPersonalSignup;
10
+ businessOnboarding: CustomerBusinessOnboarding;
11
+ }
12
+ export declare const defaultCustomerBuyerAccountPolicy: {
13
+ readonly allowedKinds: readonly ["personal"];
14
+ readonly personalSignup: "open";
15
+ readonly businessOnboarding: "disabled";
16
+ };
17
+ export declare const customerBuyerAccountPolicySchema: z.ZodObject<{
18
+ allowedKinds: z.ZodArray<z.ZodEnum<{
19
+ personal: "personal";
20
+ business: "business";
21
+ }>>;
22
+ personalSignup: z.ZodEnum<{
23
+ open: "open";
24
+ disabled: "disabled";
25
+ }>;
26
+ businessOnboarding: z.ZodEnum<{
27
+ open: "open";
28
+ disabled: "disabled";
29
+ request: "request";
30
+ "invite-only": "invite-only";
31
+ }>;
32
+ }, z.core.$strict>;
33
+ export declare function normalizeCustomerBuyerAccountPolicy(input?: unknown): CustomerBuyerAccountPolicy;
34
+ export declare function personalBuyerAccountId(userId: string): string;
35
+ export declare function businessBuyerAccountId(authOrganizationId: string): string;
36
+ export interface CustomerIdentitySummary {
37
+ userId: string;
38
+ name: string | null;
39
+ email: string | null;
40
+ }
41
+ export interface CustomerBusinessMembership {
42
+ id: string;
43
+ authOrganizationId: string;
44
+ relationshipOrganizationId: string | null;
45
+ name: string;
46
+ role: string;
47
+ }
48
+ export interface CustomerBuyerAccountStore {
49
+ hasActivePersonalAccount(userId: string): Promise<boolean>;
50
+ getRelationshipPersonId(userId: string): Promise<string | null>;
51
+ listBusinessMemberships(userId: string): Promise<CustomerBusinessMembership[]>;
52
+ }
53
+ interface CustomerBuyerAccountBase {
54
+ id: string;
55
+ name: string;
56
+ }
57
+ export interface PersonalCustomerBuyerAccount extends CustomerBuyerAccountBase {
58
+ kind: "personal";
59
+ authOrganizationId: null;
60
+ relationshipOrganizationId: null;
61
+ relationshipPersonId: string | null;
62
+ membershipId: null;
63
+ membershipRole: null;
64
+ }
65
+ export interface BusinessCustomerBuyerAccount extends CustomerBuyerAccountBase {
66
+ kind: "business";
67
+ authOrganizationId: string;
68
+ relationshipOrganizationId: string;
69
+ relationshipPersonId: null;
70
+ membershipId: string;
71
+ membershipRole: string;
72
+ }
73
+ export type CustomerBuyerAccount = PersonalCustomerBuyerAccount | BusinessCustomerBuyerAccount;
74
+ export type ActiveCustomerBuyerContext = CustomerBuyerAccount & {
75
+ userId: string;
76
+ };
77
+ export interface CustomerBuyerAccountList {
78
+ accounts: CustomerBuyerAccount[];
79
+ activeAccount: CustomerBuyerAccount | null;
80
+ policy: CustomerBuyerAccountPolicy;
81
+ requiresSelection: boolean;
82
+ }
83
+ /**
84
+ * Lists only usable buyer contexts. An unmapped Better Auth organization is
85
+ * intentionally omitted because it is not a canonical business buyer.
86
+ */
87
+ export declare function listCustomerBuyerAccounts(input: {
88
+ identity: CustomerIdentitySummary;
89
+ activeAuthOrganizationId: string | null;
90
+ policy?: Partial<CustomerBuyerAccountPolicy> | null;
91
+ store: CustomerBuyerAccountStore;
92
+ }): Promise<CustomerBuyerAccountList>;
93
+ /** Revalidates the active Better Auth organization against live membership. */
94
+ export declare function resolveActiveCustomerBuyerContext(input: {
95
+ identity: CustomerIdentitySummary;
96
+ activeAuthOrganizationId: string | null;
97
+ policy?: Partial<CustomerBuyerAccountPolicy> | null;
98
+ store: CustomerBuyerAccountStore;
99
+ }): Promise<ActiveCustomerBuyerContext | null>;
100
+ /** Validates a requested account without trusting a client-supplied organization id. */
101
+ export declare function selectCustomerBuyerAccount(input: {
102
+ accountId: string;
103
+ identity: CustomerIdentitySummary;
104
+ activeAuthOrganizationId: string | null;
105
+ policy?: Partial<CustomerBuyerAccountPolicy> | null;
106
+ store: CustomerBuyerAccountStore;
107
+ }): Promise<CustomerBuyerAccount | null>;
108
+ export declare function createDrizzleCustomerBuyerAccountStore(db: VoyantDb): CustomerBuyerAccountStore;
109
+ /**
110
+ * Lazily and idempotently materializes the entitlement from the immutable
111
+ * eligibility stamp. Current policy changes cannot grant an entitlement
112
+ * retroactively, and revoked rows are never revived.
113
+ */
114
+ export declare function repairCustomerPersonalBuyerAccountEntitlement(db: VoyantDb, userId: string): Promise<boolean>;
115
+ /**
116
+ * Deployment-owned seam for open/request business onboarding. Implementations
117
+ * must create or select the canonical Relationships Organization before they
118
+ * create the Better Auth membership container and return both ids.
119
+ */
120
+ export interface CustomerBusinessAccountOnboardingPort<Context = unknown> {
121
+ create(input: {
122
+ userId: string;
123
+ name: string;
124
+ context: Context;
125
+ }): Promise<BusinessCustomerBuyerAccount>;
126
+ request?(input: {
127
+ userId: string;
128
+ name: string;
129
+ context: Context;
130
+ }): Promise<{
131
+ id: string;
132
+ }>;
133
+ }
134
+ export {};
@@ -0,0 +1,177 @@
1
+ import { customerAuthMember, customerAuthOrganization, customerAuthPersonalBuyerAccount, customerAuthUser, } from "@voyant-travel/db/schema/iam";
2
+ import { asc, eq } from "drizzle-orm";
3
+ import { z } from "zod";
4
+ export const customerBuyerAccountKinds = ["personal", "business"];
5
+ export const defaultCustomerBuyerAccountPolicy = {
6
+ allowedKinds: ["personal"],
7
+ personalSignup: "open",
8
+ businessOnboarding: "disabled",
9
+ };
10
+ export const customerBuyerAccountPolicySchema = z
11
+ .object({
12
+ allowedKinds: z.array(z.enum(customerBuyerAccountKinds)).min(1).max(2),
13
+ personalSignup: z.enum(["open", "disabled"]),
14
+ businessOnboarding: z.enum(["disabled", "open", "request", "invite-only"]),
15
+ })
16
+ .strict()
17
+ .superRefine((policy, ctx) => {
18
+ if (new Set(policy.allowedKinds).size !== policy.allowedKinds.length) {
19
+ ctx.addIssue({
20
+ code: "custom",
21
+ path: ["allowedKinds"],
22
+ message: "allowedKinds must be unique",
23
+ });
24
+ }
25
+ if (!policy.allowedKinds.includes("personal") && policy.personalSignup !== "disabled") {
26
+ ctx.addIssue({
27
+ code: "custom",
28
+ path: ["personalSignup"],
29
+ message: "personalSignup must be disabled when personal accounts are not allowed",
30
+ });
31
+ }
32
+ if (policy.allowedKinds.includes("business") === (policy.businessOnboarding === "disabled")) {
33
+ ctx.addIssue({
34
+ code: "custom",
35
+ path: ["businessOnboarding"],
36
+ message: "businessOnboarding must be disabled exactly when business accounts are not allowed",
37
+ });
38
+ }
39
+ });
40
+ export function normalizeCustomerBuyerAccountPolicy(input) {
41
+ if (input === undefined || input === null) {
42
+ return { ...defaultCustomerBuyerAccountPolicy };
43
+ }
44
+ const parsed = customerBuyerAccountPolicySchema.parse(input);
45
+ return {
46
+ ...parsed,
47
+ allowedKinds: customerBuyerAccountKinds.filter((kind) => parsed.allowedKinds.includes(kind)),
48
+ };
49
+ }
50
+ export function personalBuyerAccountId(userId) {
51
+ return `personal:${userId}`;
52
+ }
53
+ export function businessBuyerAccountId(authOrganizationId) {
54
+ return `business:${authOrganizationId}`;
55
+ }
56
+ function personalAccount(identity, relationshipPersonId) {
57
+ return {
58
+ id: personalBuyerAccountId(identity.userId),
59
+ kind: "personal",
60
+ name: identity.name?.trim() || identity.email?.trim() || "Personal account",
61
+ authOrganizationId: null,
62
+ relationshipOrganizationId: null,
63
+ relationshipPersonId,
64
+ membershipId: null,
65
+ membershipRole: null,
66
+ };
67
+ }
68
+ function businessAccount(membership) {
69
+ return {
70
+ id: businessBuyerAccountId(membership.authOrganizationId),
71
+ kind: "business",
72
+ name: membership.name,
73
+ authOrganizationId: membership.authOrganizationId,
74
+ relationshipOrganizationId: membership.relationshipOrganizationId,
75
+ relationshipPersonId: null,
76
+ membershipId: membership.id,
77
+ membershipRole: membership.role,
78
+ };
79
+ }
80
+ function businessAccountsEnabled(policy) {
81
+ return policy.allowedKinds.includes("business");
82
+ }
83
+ function hasRelationshipOrganization(membership) {
84
+ return Boolean(membership.relationshipOrganizationId);
85
+ }
86
+ /**
87
+ * Lists only usable buyer contexts. An unmapped Better Auth organization is
88
+ * intentionally omitted because it is not a canonical business buyer.
89
+ */
90
+ export async function listCustomerBuyerAccounts(input) {
91
+ const policy = normalizeCustomerBuyerAccountPolicy(input.policy);
92
+ const accounts = [];
93
+ if (policy.allowedKinds.includes("personal")) {
94
+ if (await input.store.hasActivePersonalAccount(input.identity.userId)) {
95
+ accounts.push(personalAccount(input.identity, await input.store.getRelationshipPersonId(input.identity.userId)));
96
+ }
97
+ }
98
+ if (businessAccountsEnabled(policy)) {
99
+ const memberships = await input.store.listBusinessMemberships(input.identity.userId);
100
+ accounts.push(...memberships.filter(hasRelationshipOrganization).map(businessAccount));
101
+ }
102
+ const activeAccount = input.activeAuthOrganizationId
103
+ ? (accounts.find((account) => account.authOrganizationId === input.activeAuthOrganizationId) ??
104
+ null)
105
+ : (accounts.find((account) => account.kind === "personal") ?? null);
106
+ return {
107
+ accounts,
108
+ activeAccount,
109
+ policy,
110
+ requiresSelection: activeAccount === null,
111
+ };
112
+ }
113
+ /** Revalidates the active Better Auth organization against live membership. */
114
+ export async function resolveActiveCustomerBuyerContext(input) {
115
+ const listed = await listCustomerBuyerAccounts(input);
116
+ return listed.activeAccount ? { ...listed.activeAccount, userId: input.identity.userId } : null;
117
+ }
118
+ /** Validates a requested account without trusting a client-supplied organization id. */
119
+ export async function selectCustomerBuyerAccount(input) {
120
+ const listed = await listCustomerBuyerAccounts(input);
121
+ return listed.accounts.find((account) => account.id === input.accountId) ?? null;
122
+ }
123
+ export function createDrizzleCustomerBuyerAccountStore(db) {
124
+ return {
125
+ async hasActivePersonalAccount(userId) {
126
+ const [row] = await db
127
+ .select({ revokedAt: customerAuthPersonalBuyerAccount.revokedAt })
128
+ .from(customerAuthPersonalBuyerAccount)
129
+ .where(eq(customerAuthPersonalBuyerAccount.userId, userId))
130
+ .limit(1);
131
+ return row?.revokedAt === null;
132
+ },
133
+ async getRelationshipPersonId(userId) {
134
+ const [row] = await db
135
+ .select({ relationshipPersonId: customerAuthUser.relationshipPersonId })
136
+ .from(customerAuthUser)
137
+ .where(eq(customerAuthUser.id, userId))
138
+ .limit(1);
139
+ return row?.relationshipPersonId ?? null;
140
+ },
141
+ async listBusinessMemberships(userId) {
142
+ return db
143
+ .select({
144
+ id: customerAuthMember.id,
145
+ authOrganizationId: customerAuthMember.organizationId,
146
+ relationshipOrganizationId: customerAuthOrganization.relationshipOrganizationId,
147
+ name: customerAuthOrganization.name,
148
+ role: customerAuthMember.role,
149
+ })
150
+ .from(customerAuthMember)
151
+ .innerJoin(customerAuthOrganization, eq(customerAuthOrganization.id, customerAuthMember.organizationId))
152
+ .where(eq(customerAuthMember.userId, userId))
153
+ .orderBy(asc(customerAuthOrganization.name), asc(customerAuthMember.createdAt));
154
+ },
155
+ };
156
+ }
157
+ /**
158
+ * Lazily and idempotently materializes the entitlement from the immutable
159
+ * eligibility stamp. Current policy changes cannot grant an entitlement
160
+ * retroactively, and revoked rows are never revived.
161
+ */
162
+ export async function repairCustomerPersonalBuyerAccountEntitlement(db, userId) {
163
+ const [candidate] = await db
164
+ .select({
165
+ eligible: customerAuthUser.personalBuyerEntitlementEligible,
166
+ entitlementUserId: customerAuthPersonalBuyerAccount.userId,
167
+ })
168
+ .from(customerAuthUser)
169
+ .leftJoin(customerAuthPersonalBuyerAccount, eq(customerAuthPersonalBuyerAccount.userId, customerAuthUser.id))
170
+ .where(eq(customerAuthUser.id, userId))
171
+ .limit(1);
172
+ if (!candidate?.eligible || candidate.entitlementUserId)
173
+ return false;
174
+ await db.insert(customerAuthPersonalBuyerAccount).values({ userId }).onConflictDoNothing();
175
+ return true;
176
+ }
177
+ //# sourceMappingURL=customer-buyer-accounts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"customer-buyer-accounts.js","sourceRoot":"","sources":["../src/customer-buyer-accounts.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n customerAuthMember,\n customerAuthOrganization,\n customerAuthPersonalBuyerAccount,\n customerAuthUser,\n} from \"@voyant-travel/db/schema/iam\"\nimport type { VoyantDb } from \"@voyant-travel/hono\"\nimport { asc, eq } from \"drizzle-orm\"\nimport { z } from \"zod\"\n\nexport const customerBuyerAccountKinds = [\"personal\", \"business\"] as const\nexport type CustomerBuyerAccountKind = (typeof customerBuyerAccountKinds)[number]\nexport type CustomerPersonalSignup = \"open\" | \"disabled\"\nexport type CustomerBusinessOnboarding = \"disabled\" | \"open\" | \"request\" | \"invite-only\"\n\nexport interface CustomerBuyerAccountPolicy {\n allowedKinds: readonly CustomerBuyerAccountKind[]\n personalSignup: CustomerPersonalSignup\n businessOnboarding: CustomerBusinessOnboarding\n}\n\nexport const defaultCustomerBuyerAccountPolicy = {\n allowedKinds: [\"personal\"],\n personalSignup: \"open\",\n businessOnboarding: \"disabled\",\n} as const satisfies CustomerBuyerAccountPolicy\n\nexport const customerBuyerAccountPolicySchema = z\n .object({\n allowedKinds: z.array(z.enum(customerBuyerAccountKinds)).min(1).max(2),\n personalSignup: z.enum([\"open\", \"disabled\"]),\n businessOnboarding: z.enum([\"disabled\", \"open\", \"request\", \"invite-only\"]),\n })\n .strict()\n .superRefine((policy, ctx) => {\n if (new Set(policy.allowedKinds).size !== policy.allowedKinds.length) {\n ctx.addIssue({\n code: \"custom\",\n path: [\"allowedKinds\"],\n message: \"allowedKinds must be unique\",\n })\n }\n if (!policy.allowedKinds.includes(\"personal\") && policy.personalSignup !== \"disabled\") {\n ctx.addIssue({\n code: \"custom\",\n path: [\"personalSignup\"],\n message: \"personalSignup must be disabled when personal accounts are not allowed\",\n })\n }\n if (policy.allowedKinds.includes(\"business\") === (policy.businessOnboarding === \"disabled\")) {\n ctx.addIssue({\n code: \"custom\",\n path: [\"businessOnboarding\"],\n message:\n \"businessOnboarding must be disabled exactly when business accounts are not allowed\",\n })\n }\n })\n\nexport function normalizeCustomerBuyerAccountPolicy(input?: unknown): CustomerBuyerAccountPolicy {\n if (input === undefined || input === null) {\n return { ...defaultCustomerBuyerAccountPolicy }\n }\n const parsed = customerBuyerAccountPolicySchema.parse(input)\n return {\n ...parsed,\n allowedKinds: customerBuyerAccountKinds.filter((kind) => parsed.allowedKinds.includes(kind)),\n }\n}\n\nexport function personalBuyerAccountId(userId: string): string {\n return `personal:${userId}`\n}\n\nexport function businessBuyerAccountId(authOrganizationId: string): string {\n return `business:${authOrganizationId}`\n}\n\nexport interface CustomerIdentitySummary {\n userId: string\n name: string | null\n email: string | null\n}\n\nexport interface CustomerBusinessMembership {\n id: string\n authOrganizationId: string\n relationshipOrganizationId: string | null\n name: string\n role: string\n}\n\nexport interface CustomerBuyerAccountStore {\n hasActivePersonalAccount(userId: string): Promise<boolean>\n getRelationshipPersonId(userId: string): Promise<string | null>\n listBusinessMemberships(userId: string): Promise<CustomerBusinessMembership[]>\n}\n\ninterface CustomerBuyerAccountBase {\n id: string\n name: string\n}\n\nexport interface PersonalCustomerBuyerAccount extends CustomerBuyerAccountBase {\n kind: \"personal\"\n authOrganizationId: null\n relationshipOrganizationId: null\n relationshipPersonId: string | null\n membershipId: null\n membershipRole: null\n}\n\nexport interface BusinessCustomerBuyerAccount extends CustomerBuyerAccountBase {\n kind: \"business\"\n authOrganizationId: string\n relationshipOrganizationId: string\n relationshipPersonId: null\n membershipId: string\n membershipRole: string\n}\n\nexport type CustomerBuyerAccount = PersonalCustomerBuyerAccount | BusinessCustomerBuyerAccount\n\nexport type ActiveCustomerBuyerContext = CustomerBuyerAccount & { userId: string }\n\nexport interface CustomerBuyerAccountList {\n accounts: CustomerBuyerAccount[]\n activeAccount: CustomerBuyerAccount | null\n policy: CustomerBuyerAccountPolicy\n requiresSelection: boolean\n}\n\nfunction personalAccount(\n identity: CustomerIdentitySummary,\n relationshipPersonId: string | null,\n): PersonalCustomerBuyerAccount {\n return {\n id: personalBuyerAccountId(identity.userId),\n kind: \"personal\",\n name: identity.name?.trim() || identity.email?.trim() || \"Personal account\",\n authOrganizationId: null,\n relationshipOrganizationId: null,\n relationshipPersonId,\n membershipId: null,\n membershipRole: null,\n }\n}\n\nfunction businessAccount(\n membership: CustomerBusinessMembership & { relationshipOrganizationId: string },\n): BusinessCustomerBuyerAccount {\n return {\n id: businessBuyerAccountId(membership.authOrganizationId),\n kind: \"business\",\n name: membership.name,\n authOrganizationId: membership.authOrganizationId,\n relationshipOrganizationId: membership.relationshipOrganizationId,\n relationshipPersonId: null,\n membershipId: membership.id,\n membershipRole: membership.role,\n }\n}\n\nfunction businessAccountsEnabled(policy: CustomerBuyerAccountPolicy): boolean {\n return policy.allowedKinds.includes(\"business\")\n}\n\nfunction hasRelationshipOrganization(\n membership: CustomerBusinessMembership,\n): membership is CustomerBusinessMembership & { relationshipOrganizationId: string } {\n return Boolean(membership.relationshipOrganizationId)\n}\n\n/**\n * Lists only usable buyer contexts. An unmapped Better Auth organization is\n * intentionally omitted because it is not a canonical business buyer.\n */\nexport async function listCustomerBuyerAccounts(input: {\n identity: CustomerIdentitySummary\n activeAuthOrganizationId: string | null\n policy?: Partial<CustomerBuyerAccountPolicy> | null\n store: CustomerBuyerAccountStore\n}): Promise<CustomerBuyerAccountList> {\n const policy = normalizeCustomerBuyerAccountPolicy(input.policy)\n const accounts: CustomerBuyerAccount[] = []\n\n if (policy.allowedKinds.includes(\"personal\")) {\n if (await input.store.hasActivePersonalAccount(input.identity.userId)) {\n accounts.push(\n personalAccount(\n input.identity,\n await input.store.getRelationshipPersonId(input.identity.userId),\n ),\n )\n }\n }\n\n if (businessAccountsEnabled(policy)) {\n const memberships = await input.store.listBusinessMemberships(input.identity.userId)\n accounts.push(...memberships.filter(hasRelationshipOrganization).map(businessAccount))\n }\n\n const activeAccount = input.activeAuthOrganizationId\n ? (accounts.find((account) => account.authOrganizationId === input.activeAuthOrganizationId) ??\n null)\n : (accounts.find((account) => account.kind === \"personal\") ?? null)\n\n return {\n accounts,\n activeAccount,\n policy,\n requiresSelection: activeAccount === null,\n }\n}\n\n/** Revalidates the active Better Auth organization against live membership. */\nexport async function resolveActiveCustomerBuyerContext(input: {\n identity: CustomerIdentitySummary\n activeAuthOrganizationId: string | null\n policy?: Partial<CustomerBuyerAccountPolicy> | null\n store: CustomerBuyerAccountStore\n}): Promise<ActiveCustomerBuyerContext | null> {\n const listed = await listCustomerBuyerAccounts(input)\n return listed.activeAccount ? { ...listed.activeAccount, userId: input.identity.userId } : null\n}\n\n/** Validates a requested account without trusting a client-supplied organization id. */\nexport async function selectCustomerBuyerAccount(input: {\n accountId: string\n identity: CustomerIdentitySummary\n activeAuthOrganizationId: string | null\n policy?: Partial<CustomerBuyerAccountPolicy> | null\n store: CustomerBuyerAccountStore\n}): Promise<CustomerBuyerAccount | null> {\n const listed = await listCustomerBuyerAccounts(input)\n return listed.accounts.find((account) => account.id === input.accountId) ?? null\n}\n\nexport function createDrizzleCustomerBuyerAccountStore(db: VoyantDb): CustomerBuyerAccountStore {\n return {\n async hasActivePersonalAccount(userId) {\n const [row] = await db\n .select({ revokedAt: customerAuthPersonalBuyerAccount.revokedAt })\n .from(customerAuthPersonalBuyerAccount)\n .where(eq(customerAuthPersonalBuyerAccount.userId, userId))\n .limit(1)\n return row?.revokedAt === null\n },\n async getRelationshipPersonId(userId) {\n const [row] = await db\n .select({ relationshipPersonId: customerAuthUser.relationshipPersonId })\n .from(customerAuthUser)\n .where(eq(customerAuthUser.id, userId))\n .limit(1)\n return row?.relationshipPersonId ?? null\n },\n async listBusinessMemberships(userId) {\n return db\n .select({\n id: customerAuthMember.id,\n authOrganizationId: customerAuthMember.organizationId,\n relationshipOrganizationId: customerAuthOrganization.relationshipOrganizationId,\n name: customerAuthOrganization.name,\n role: customerAuthMember.role,\n })\n .from(customerAuthMember)\n .innerJoin(\n customerAuthOrganization,\n eq(customerAuthOrganization.id, customerAuthMember.organizationId),\n )\n .where(eq(customerAuthMember.userId, userId))\n .orderBy(asc(customerAuthOrganization.name), asc(customerAuthMember.createdAt))\n },\n }\n}\n\n/**\n * Lazily and idempotently materializes the entitlement from the immutable\n * eligibility stamp. Current policy changes cannot grant an entitlement\n * retroactively, and revoked rows are never revived.\n */\nexport async function repairCustomerPersonalBuyerAccountEntitlement(\n db: VoyantDb,\n userId: string,\n): Promise<boolean> {\n const [candidate] = await db\n .select({\n eligible: customerAuthUser.personalBuyerEntitlementEligible,\n entitlementUserId: customerAuthPersonalBuyerAccount.userId,\n })\n .from(customerAuthUser)\n .leftJoin(\n customerAuthPersonalBuyerAccount,\n eq(customerAuthPersonalBuyerAccount.userId, customerAuthUser.id),\n )\n .where(eq(customerAuthUser.id, userId))\n .limit(1)\n\n if (!candidate?.eligible || candidate.entitlementUserId) return false\n\n await db.insert(customerAuthPersonalBuyerAccount).values({ userId }).onConflictDoNothing()\n return true\n}\n\n/**\n * Deployment-owned seam for open/request business onboarding. Implementations\n * must create or select the canonical Relationships Organization before they\n * create the Better Auth membership container and return both ids.\n */\nexport interface CustomerBusinessAccountOnboardingPort<Context = unknown> {\n create(input: {\n userId: string\n name: string\n context: Context\n }): Promise<BusinessCustomerBuyerAccount>\n request?(input: { userId: string; name: string; context: Context }): Promise<{ id: string }>\n}\n"]}
@@ -7,6 +7,8 @@ export declare function isDeactivatedProviderId(providerId: string): boolean;
7
7
  export declare function isLocalMemberDeactivated(db: VoyantDb, userId: string): Promise<boolean>;
8
8
  export declare function isLocalMemberEmailDeactivated(db: VoyantDb, email: string): Promise<boolean>;
9
9
  export declare function isLocalMemberSessionActive(db: VoyantDb, sessionId: string, userId: string): Promise<boolean>;
10
+ /** Customer-realm session liveness check used to defeat stale cookie-cache grants. */
11
+ export declare function isCustomerSessionActive(db: VoyantDb, sessionId: string, userId: string): Promise<boolean>;
10
12
  export interface LocalMemberAccessResolver {
11
13
  isEmailDeactivated(email: string): Promise<boolean>;
12
14
  isSessionActive(sessionId: string, userId: string): Promise<boolean>;
@@ -1,7 +1,7 @@
1
- import { authAccount, authSession, authUser } from "@voyant-travel/db/schema/iam";
1
+ import { authAccount, authSession, authUser, customerAuthSession, } from "@voyant-travel/db/schema/iam";
2
2
  import { APIError, createAuthMiddleware, getSessionFromCtx } from "better-auth/api";
3
3
  import { deleteSessionCookie } from "better-auth/cookies";
4
- import { and, eq } from "drizzle-orm";
4
+ import { and, eq, gt } from "drizzle-orm";
5
5
  export const DEACTIVATED_PROVIDER_PREFIX = "voyant-deactivated:";
6
6
  export function localProviderIdForStatus(providerId, status) {
7
7
  if (status === "deactivated") {
@@ -43,6 +43,15 @@ export async function isLocalMemberSessionActive(db, sessionId, userId) {
43
43
  .limit(1);
44
44
  return sessions.length > 0;
45
45
  }
46
+ /** Customer-realm session liveness check used to defeat stale cookie-cache grants. */
47
+ export async function isCustomerSessionActive(db, sessionId, userId) {
48
+ const sessions = await db
49
+ .select({ id: customerAuthSession.id })
50
+ .from(customerAuthSession)
51
+ .where(and(eq(customerAuthSession.id, sessionId), eq(customerAuthSession.userId, userId), gt(customerAuthSession.expiresAt, new Date())))
52
+ .limit(1);
53
+ return sessions.length > 0;
54
+ }
46
55
  const ACCESS_DENIED_MESSAGE = "This account is deactivated.";
47
56
  function requestEmail(context) {
48
57
  const path = context.path ?? "";
@@ -1 +1 @@
1
- {"version":3,"file":"local-member-access.js","sourceRoot":"","sources":["../src/local-member-access.ts"],"names":[],"mappings":"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","sourcesContent":["import { authAccount, authSession, authUser } from \"@voyant-travel/db/schema/iam\"\nimport type { VoyantDb } from \"@voyant-travel/hono\"\nimport { APIError, createAuthMiddleware, getSessionFromCtx } from \"better-auth/api\"\nimport { deleteSessionCookie } from \"better-auth/cookies\"\nimport type { BetterAuthPlugin } from \"better-auth/types\"\nimport { and, eq } from \"drizzle-orm\"\n\nimport type { TeamMemberStatus } from \"./team-management-runtime-port.js\"\n\nexport const DEACTIVATED_PROVIDER_PREFIX = \"voyant-deactivated:\"\n\nexport function localProviderIdForStatus(providerId: string, status: TeamMemberStatus): string {\n if (status === \"deactivated\") {\n return providerId.startsWith(DEACTIVATED_PROVIDER_PREFIX)\n ? providerId\n : `${DEACTIVATED_PROVIDER_PREFIX}${providerId}`\n }\n return providerId.startsWith(DEACTIVATED_PROVIDER_PREFIX)\n ? providerId.slice(DEACTIVATED_PROVIDER_PREFIX.length)\n : providerId\n}\n\nexport function isDeactivatedProviderId(providerId: string): boolean {\n return providerId.startsWith(DEACTIVATED_PROVIDER_PREFIX)\n}\n\nasync function accountProvidersForUser(db: VoyantDb, userId: string): Promise<string[]> {\n const accounts = await db\n .select({ providerId: authAccount.providerId })\n .from(authAccount)\n .where(eq(authAccount.userId, userId))\n return accounts.map((account) => account.providerId)\n}\n\nexport async function isLocalMemberDeactivated(db: VoyantDb, userId: string): Promise<boolean> {\n const providerIds = await accountProvidersForUser(db, userId)\n return providerIds.length > 0 && providerIds.every(isDeactivatedProviderId)\n}\n\nexport async function isLocalMemberEmailDeactivated(db: VoyantDb, email: string): Promise<boolean> {\n const accounts = await db\n .select({ providerId: authAccount.providerId })\n .from(authAccount)\n .innerJoin(authUser, eq(authUser.id, authAccount.userId))\n .where(and(eq(authUser.email, email.trim().toLowerCase()), eq(authAccount.userId, authUser.id)))\n return (\n accounts.length > 0 && accounts.every((account) => isDeactivatedProviderId(account.providerId))\n )\n}\n\nexport async function isLocalMemberSessionActive(\n db: VoyantDb,\n sessionId: string,\n userId: string,\n): Promise<boolean> {\n const sessions = await db\n .select({ id: authSession.id })\n .from(authSession)\n .where(and(eq(authSession.id, sessionId), eq(authSession.userId, userId)))\n .limit(1)\n return sessions.length > 0\n}\n\nexport interface LocalMemberAccessResolver {\n isEmailDeactivated(email: string): Promise<boolean>\n isSessionActive(sessionId: string, userId: string): Promise<boolean>\n isUserDeactivated(userId: string): Promise<boolean>\n}\n\nconst ACCESS_DENIED_MESSAGE = \"This account is deactivated.\"\n\nfunction requestEmail(context: { path?: string; body?: unknown; query?: unknown }): string | null {\n const path = context.path ?? \"\"\n if (!path.includes(\"email-otp\") && !path.includes(\"email\") && !path.includes(\"password\")) {\n return null\n }\n\n for (const input of [context.body, context.query]) {\n if (!input || typeof input !== \"object\") continue\n const email = (input as Record<string, unknown>).email\n if (typeof email === \"string\" && email.trim().length > 0) {\n return email.trim().toLowerCase()\n }\n }\n return null\n}\n\nfunction denyAccess(): never {\n throw new APIError(\"FORBIDDEN\", { message: ACCESS_DENIED_MESSAGE })\n}\n\nexport function createLocalMemberAccessPlugin(\n resolver: LocalMemberAccessResolver,\n): BetterAuthPlugin {\n return {\n id: \"voyant-local-member-access\",\n init() {\n return {\n options: {\n databaseHooks: {\n account: {\n create: {\n before: async (account) => {\n if (await resolver.isUserDeactivated(account.userId)) denyAccess()\n },\n },\n },\n session: {\n create: {\n before: async (session) => {\n if (await resolver.isUserDeactivated(session.userId)) denyAccess()\n },\n },\n },\n },\n },\n }\n },\n hooks: {\n before: [\n {\n matcher: () => true,\n handler: createAuthMiddleware(async (context) => {\n const session = await getSessionFromCtx(context)\n if (session) {\n const [isDeactivated, isSessionActive] = await Promise.all([\n resolver.isUserDeactivated(session.user.id),\n resolver.isSessionActive(session.session.id, session.user.id),\n ])\n if (isDeactivated || !isSessionActive) {\n deleteSessionCookie(context)\n if (context.path === \"/get-session\") return context.json(null)\n denyAccess()\n }\n }\n\n const email = requestEmail(context)\n if (email && (await resolver.isEmailDeactivated(email))) denyAccess()\n }),\n },\n ],\n },\n }\n}\n"]}
1
+ {"version":3,"file":"local-member-access.js","sourceRoot":"","sources":["../src/local-member-access.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,WAAW,EACX,WAAW,EACX,QAAQ,EACR,mBAAmB,GACpB,MAAM,8BAA8B,CAAA;AAErC,OAAO,EAAE,QAAQ,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AACnF,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAA;AAEzD,OAAO,EAAE,GAAG,EAAE,EAAE,EAAE,EAAE,EAAE,MAAM,aAAa,CAAA;AAIzC,MAAM,CAAC,MAAM,2BAA2B,GAAG,qBAAqB,CAAA;AAEhE,MAAM,UAAU,wBAAwB,CAAC,UAAkB,EAAE,MAAwB;IACnF,IAAI,MAAM,KAAK,aAAa,EAAE,CAAC;QAC7B,OAAO,UAAU,CAAC,UAAU,CAAC,2BAA2B,CAAC;YACvD,CAAC,CAAC,UAAU;YACZ,CAAC,CAAC,GAAG,2BAA2B,GAAG,UAAU,EAAE,CAAA;IACnD,CAAC;IACD,OAAO,UAAU,CAAC,UAAU,CAAC,2BAA2B,CAAC;QACvD,CAAC,CAAC,UAAU,CAAC,KAAK,CAAC,2BAA2B,CAAC,MAAM,CAAC;QACtD,CAAC,CAAC,UAAU,CAAA;AAChB,CAAC;AAED,MAAM,UAAU,uBAAuB,CAAC,UAAkB;IACxD,OAAO,UAAU,CAAC,UAAU,CAAC,2BAA2B,CAAC,CAAA;AAC3D,CAAC;AAED,KAAK,UAAU,uBAAuB,CAAC,EAAY,EAAE,MAAc;IACjE,MAAM,QAAQ,GAAG,MAAM,EAAE;SACtB,MAAM,CAAC,EAAE,UAAU,EAAE,WAAW,CAAC,UAAU,EAAE,CAAC;SAC9C,IAAI,CAAC,WAAW,CAAC;SACjB,KAAK,CAAC,EAAE,CAAC,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;IACxC,OAAO,QAAQ,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,UAAU,CAAC,CAAA;AACtD,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,wBAAwB,CAAC,EAAY,EAAE,MAAc;IACzE,MAAM,WAAW,GAAG,MAAM,uBAAuB,CAAC,EAAE,EAAE,MAAM,CAAC,CAAA;IAC7D,OAAO,WAAW,CAAC,MAAM,GAAG,CAAC,IAAI,WAAW,CAAC,KAAK,CAAC,uBAAuB,CAAC,CAAA;AAC7E,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,6BAA6B,CAAC,EAAY,EAAE,KAAa;IAC7E,MAAM,QAAQ,GAAG,MAAM,EAAE;SACtB,MAAM,CAAC,EAAE,UAAU,EAAE,WAAW,CAAC,UAAU,EAAE,CAAC;SAC9C,IAAI,CAAC,WAAW,CAAC;SACjB,SAAS,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,EAAE,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;SACxD,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,EAAE,EAAE,CAAC,WAAW,CAAC,MAAM,EAAE,QAAQ,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;IAClG,OAAO,CACL,QAAQ,CAAC,MAAM,GAAG,CAAC,IAAI,QAAQ,CAAC,KAAK,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,uBAAuB,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,CAChG,CAAA;AACH,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,0BAA0B,CAC9C,EAAY,EACZ,SAAiB,EACjB,MAAc;IAEd,MAAM,QAAQ,GAAG,MAAM,EAAE;SACtB,MAAM,CAAC,EAAE,EAAE,EAAE,WAAW,CAAC,EAAE,EAAE,CAAC;SAC9B,IAAI,CAAC,WAAW,CAAC;SACjB,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC,WAAW,CAAC,EAAE,EAAE,SAAS,CAAC,EAAE,EAAE,CAAC,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;SACzE,KAAK,CAAC,CAAC,CAAC,CAAA;IACX,OAAO,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAA;AAC5B,CAAC;AAED,sFAAsF;AACtF,MAAM,CAAC,KAAK,UAAU,uBAAuB,CAC3C,EAAY,EACZ,SAAiB,EACjB,MAAc;IAEd,MAAM,QAAQ,GAAG,MAAM,EAAE;SACtB,MAAM,CAAC,EAAE,EAAE,EAAE,mBAAmB,CAAC,EAAE,EAAE,CAAC;SACtC,IAAI,CAAC,mBAAmB,CAAC;SACzB,KAAK,CACJ,GAAG,CACD,EAAE,CAAC,mBAAmB,CAAC,EAAE,EAAE,SAAS,CAAC,EACrC,EAAE,CAAC,mBAAmB,CAAC,MAAM,EAAE,MAAM,CAAC,EACtC,EAAE,CAAC,mBAAmB,CAAC,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,CAC9C,CACF;SACA,KAAK,CAAC,CAAC,CAAC,CAAA;IACX,OAAO,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAA;AAC5B,CAAC;AAQD,MAAM,qBAAqB,GAAG,8BAA8B,CAAA;AAE5D,SAAS,YAAY,CAAC,OAA2D;IAC/E,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,EAAE,CAAA;IAC/B,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC;QACzF,OAAO,IAAI,CAAA;IACb,CAAC;IAED,KAAK,MAAM,KAAK,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QAClD,IAAI,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ;YAAE,SAAQ;QACjD,MAAM,KAAK,GAAI,KAAiC,CAAC,KAAK,CAAA;QACtD,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACzD,OAAO,KAAK,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAA;QACnC,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAA;AACb,CAAC;AAED,SAAS,UAAU;IACjB,MAAM,IAAI,QAAQ,CAAC,WAAW,EAAE,EAAE,OAAO,EAAE,qBAAqB,EAAE,CAAC,CAAA;AACrE,CAAC;AAED,MAAM,UAAU,6BAA6B,CAC3C,QAAmC;IAEnC,OAAO;QACL,EAAE,EAAE,4BAA4B;QAChC,IAAI;YACF,OAAO;gBACL,OAAO,EAAE;oBACP,aAAa,EAAE;wBACb,OAAO,EAAE;4BACP,MAAM,EAAE;gCACN,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE;oCACxB,IAAI,MAAM,QAAQ,CAAC,iBAAiB,CAAC,OAAO,CAAC,MAAM,CAAC;wCAAE,UAAU,EAAE,CAAA;gCACpE,CAAC;6BACF;yBACF;wBACD,OAAO,EAAE;4BACP,MAAM,EAAE;gCACN,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE;oCACxB,IAAI,MAAM,QAAQ,CAAC,iBAAiB,CAAC,OAAO,CAAC,MAAM,CAAC;wCAAE,UAAU,EAAE,CAAA;gCACpE,CAAC;6BACF;yBACF;qBACF;iBACF;aACF,CAAA;QACH,CAAC;QACD,KAAK,EAAE;YACL,MAAM,EAAE;gBACN;oBACE,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI;oBACnB,OAAO,EAAE,oBAAoB,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE;wBAC9C,MAAM,OAAO,GAAG,MAAM,iBAAiB,CAAC,OAAO,CAAC,CAAA;wBAChD,IAAI,OAAO,EAAE,CAAC;4BACZ,MAAM,CAAC,aAAa,EAAE,eAAe,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC;gCACzD,QAAQ,CAAC,iBAAiB,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;gCAC3C,QAAQ,CAAC,eAAe,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,EAAE,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;6BAC9D,CAAC,CAAA;4BACF,IAAI,aAAa,IAAI,CAAC,eAAe,EAAE,CAAC;gCACtC,mBAAmB,CAAC,OAAO,CAAC,CAAA;gCAC5B,IAAI,OAAO,CAAC,IAAI,KAAK,cAAc;oCAAE,OAAO,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;gCAC9D,UAAU,EAAE,CAAA;4BACd,CAAC;wBACH,CAAC;wBAED,MAAM,KAAK,GAAG,YAAY,CAAC,OAAO,CAAC,CAAA;wBACnC,IAAI,KAAK,IAAI,CAAC,MAAM,QAAQ,CAAC,kBAAkB,CAAC,KAAK,CAAC,CAAC;4BAAE,UAAU,EAAE,CAAA;oBACvE,CAAC,CAAC;iBACH;aACF;SACF;KACF,CAAA;AACH,CAAC","sourcesContent":["import {\n authAccount,\n authSession,\n authUser,\n customerAuthSession,\n} from \"@voyant-travel/db/schema/iam\"\nimport type { VoyantDb } from \"@voyant-travel/hono\"\nimport { APIError, createAuthMiddleware, getSessionFromCtx } from \"better-auth/api\"\nimport { deleteSessionCookie } from \"better-auth/cookies\"\nimport type { BetterAuthPlugin } from \"better-auth/types\"\nimport { and, eq, gt } from \"drizzle-orm\"\n\nimport type { TeamMemberStatus } from \"./team-management-runtime-port.js\"\n\nexport const DEACTIVATED_PROVIDER_PREFIX = \"voyant-deactivated:\"\n\nexport function localProviderIdForStatus(providerId: string, status: TeamMemberStatus): string {\n if (status === \"deactivated\") {\n return providerId.startsWith(DEACTIVATED_PROVIDER_PREFIX)\n ? providerId\n : `${DEACTIVATED_PROVIDER_PREFIX}${providerId}`\n }\n return providerId.startsWith(DEACTIVATED_PROVIDER_PREFIX)\n ? providerId.slice(DEACTIVATED_PROVIDER_PREFIX.length)\n : providerId\n}\n\nexport function isDeactivatedProviderId(providerId: string): boolean {\n return providerId.startsWith(DEACTIVATED_PROVIDER_PREFIX)\n}\n\nasync function accountProvidersForUser(db: VoyantDb, userId: string): Promise<string[]> {\n const accounts = await db\n .select({ providerId: authAccount.providerId })\n .from(authAccount)\n .where(eq(authAccount.userId, userId))\n return accounts.map((account) => account.providerId)\n}\n\nexport async function isLocalMemberDeactivated(db: VoyantDb, userId: string): Promise<boolean> {\n const providerIds = await accountProvidersForUser(db, userId)\n return providerIds.length > 0 && providerIds.every(isDeactivatedProviderId)\n}\n\nexport async function isLocalMemberEmailDeactivated(db: VoyantDb, email: string): Promise<boolean> {\n const accounts = await db\n .select({ providerId: authAccount.providerId })\n .from(authAccount)\n .innerJoin(authUser, eq(authUser.id, authAccount.userId))\n .where(and(eq(authUser.email, email.trim().toLowerCase()), eq(authAccount.userId, authUser.id)))\n return (\n accounts.length > 0 && accounts.every((account) => isDeactivatedProviderId(account.providerId))\n )\n}\n\nexport async function isLocalMemberSessionActive(\n db: VoyantDb,\n sessionId: string,\n userId: string,\n): Promise<boolean> {\n const sessions = await db\n .select({ id: authSession.id })\n .from(authSession)\n .where(and(eq(authSession.id, sessionId), eq(authSession.userId, userId)))\n .limit(1)\n return sessions.length > 0\n}\n\n/** Customer-realm session liveness check used to defeat stale cookie-cache grants. */\nexport async function isCustomerSessionActive(\n db: VoyantDb,\n sessionId: string,\n userId: string,\n): Promise<boolean> {\n const sessions = await db\n .select({ id: customerAuthSession.id })\n .from(customerAuthSession)\n .where(\n and(\n eq(customerAuthSession.id, sessionId),\n eq(customerAuthSession.userId, userId),\n gt(customerAuthSession.expiresAt, new Date()),\n ),\n )\n .limit(1)\n return sessions.length > 0\n}\n\nexport interface LocalMemberAccessResolver {\n isEmailDeactivated(email: string): Promise<boolean>\n isSessionActive(sessionId: string, userId: string): Promise<boolean>\n isUserDeactivated(userId: string): Promise<boolean>\n}\n\nconst ACCESS_DENIED_MESSAGE = \"This account is deactivated.\"\n\nfunction requestEmail(context: { path?: string; body?: unknown; query?: unknown }): string | null {\n const path = context.path ?? \"\"\n if (!path.includes(\"email-otp\") && !path.includes(\"email\") && !path.includes(\"password\")) {\n return null\n }\n\n for (const input of [context.body, context.query]) {\n if (!input || typeof input !== \"object\") continue\n const email = (input as Record<string, unknown>).email\n if (typeof email === \"string\" && email.trim().length > 0) {\n return email.trim().toLowerCase()\n }\n }\n return null\n}\n\nfunction denyAccess(): never {\n throw new APIError(\"FORBIDDEN\", { message: ACCESS_DENIED_MESSAGE })\n}\n\nexport function createLocalMemberAccessPlugin(\n resolver: LocalMemberAccessResolver,\n): BetterAuthPlugin {\n return {\n id: \"voyant-local-member-access\",\n init() {\n return {\n options: {\n databaseHooks: {\n account: {\n create: {\n before: async (account) => {\n if (await resolver.isUserDeactivated(account.userId)) denyAccess()\n },\n },\n },\n session: {\n create: {\n before: async (session) => {\n if (await resolver.isUserDeactivated(session.userId)) denyAccess()\n },\n },\n },\n },\n },\n }\n },\n hooks: {\n before: [\n {\n matcher: () => true,\n handler: createAuthMiddleware(async (context) => {\n const session = await getSessionFromCtx(context)\n if (session) {\n const [isDeactivated, isSessionActive] = await Promise.all([\n resolver.isUserDeactivated(session.user.id),\n resolver.isSessionActive(session.session.id, session.user.id),\n ])\n if (isDeactivated || !isSessionActive) {\n deleteSessionCookie(context)\n if (context.path === \"/get-session\") return context.json(null)\n denyAccess()\n }\n }\n\n const email = requestEmail(context)\n if (email && (await resolver.isEmailDeactivated(email))) denyAccess()\n }),\n },\n ],\n },\n }\n}\n"]}
@@ -10,9 +10,10 @@
10
10
  */
11
11
  import { type NodeDatabaseEnv } from "@voyant-travel/db/runtime";
12
12
  import { type SelectApikey } from "@voyant-travel/db/schema/iam";
13
- import type { Reporter, VoyantDb, VoyantResolvedSessionAuthContext } from "@voyant-travel/hono";
13
+ import { type Reporter, type VoyantDb, type VoyantResolvedSessionAuthContext } from "@voyant-travel/hono";
14
14
  import type { AccessCatalog } from "@voyant-travel/types/api-keys";
15
15
  import { Hono } from "hono";
16
+ import { type CustomerBuyerAccountPolicy } from "./customer-buyer-accounts.js";
16
17
  import { type CreateBetterAuthOptions, type CustomerAuthMethods } from "./server.js";
17
18
  type OperatorAuthMode = "local" | "voyant-cloud";
18
19
  export interface OperatorAuthNodeEnv extends NodeDatabaseEnv {
@@ -111,6 +112,8 @@ export interface CustomerAuthRuntimeContext {
111
112
  baseURL: string;
112
113
  trustedOrigins: string[];
113
114
  methods: CustomerAuthMethods;
115
+ /** Buyer capabilities are independent from identity sign-up methods. */
116
+ accountPolicy?: CustomerBuyerAccountPolicy | null;
114
117
  }
115
118
  export interface CustomerAuthSocialPolicy {
116
119
  enabled: boolean;
@@ -124,6 +127,7 @@ export interface CustomerAuthPolicy {
124
127
  facebook: CustomerAuthSocialPolicy;
125
128
  apple: CustomerAuthSocialPolicy;
126
129
  };
130
+ accountPolicy: CustomerBuyerAccountPolicy;
127
131
  }
128
132
  /**
129
133
  * Better Auth is dispatched internally without the hosting `/api` prefix, but