@voyant-travel/hono 0.130.1 → 0.131.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.
@@ -9,6 +9,11 @@ import type { ApiExtension, ApiModule } from "./module.js";
9
9
  * `publicPath`/name under `/v1/public`. `anonymous: true` opens the whole mount;
10
10
  * a string array opens specific sub-paths relative to it. Pure and sorted for a
11
11
  * deterministic, snapshot-auditable result; the global list is what `requireAuth`
12
- * matches to skip auth (and stamp `actor: "customer"`).
12
+ * matches to skip auth and mark the request as explicitly anonymous.
13
13
  */
14
14
  export declare function assembleAnonymousPaths(modules: readonly ApiModule[], extensions: readonly ApiExtension[], explicit?: readonly string[]): string[];
15
+ /**
16
+ * Assemble anonymous paths that use mixed auth: valid customer sessions are
17
+ * resolved, while requests without a valid session continue as explicit guests.
18
+ */
19
+ export declare function assembleOptionalCustomerAuthPaths(modules: readonly ApiModule[], extensions: readonly ApiExtension[]): string[];
@@ -9,7 +9,7 @@ import { resolveSurfaceMountPath } from "./mount-paths.js";
9
9
  * `publicPath`/name under `/v1/public`. `anonymous: true` opens the whole mount;
10
10
  * a string array opens specific sub-paths relative to it. Pure and sorted for a
11
11
  * deterministic, snapshot-auditable result; the global list is what `requireAuth`
12
- * matches to skip auth (and stamp `actor: "customer"`).
12
+ * matches to skip auth and mark the request as explicitly anonymous.
13
13
  */
14
14
  export function assembleAnonymousPaths(modules, extensions, explicit = []) {
15
15
  const paths = new Set(explicit);
@@ -53,3 +53,29 @@ export function assembleAnonymousPaths(modules, extensions, explicit = []) {
53
53
  }
54
54
  return [...paths].sort();
55
55
  }
56
+ /**
57
+ * Assemble anonymous paths that use mixed auth: valid customer sessions are
58
+ * resolved, while requests without a valid session continue as explicit guests.
59
+ */
60
+ export function assembleOptionalCustomerAuthPaths(modules, extensions) {
61
+ const paths = new Set();
62
+ const add = (mount, declaration) => {
63
+ if (!declaration)
64
+ return;
65
+ if (declaration === true) {
66
+ paths.add(mount);
67
+ return;
68
+ }
69
+ for (const sub of declaration) {
70
+ const trimmed = sub.trim().replace(/^\/+|\/+$/g, "");
71
+ paths.add(trimmed ? `${mount}/${trimmed}` : mount);
72
+ }
73
+ };
74
+ for (const module of modules) {
75
+ add(resolveSurfaceMountPath("/v1/public", module.publicPath, module.module.name), module.optionalCustomerAuth);
76
+ }
77
+ for (const extension of extensions) {
78
+ add(resolveSurfaceMountPath("/v1/public", extension.publicPath, extension.extension.module), extension.optionalCustomerAuth);
79
+ }
80
+ return [...paths].sort();
81
+ }
package/dist/app.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import { OpenAPIHono } from "@hono/zod-openapi";
4
4
  import { createContainer, createEventBus, createQueryRunner, } from "@voyant-travel/core";
5
5
  import { createLinkServiceFactory } from "@voyant-travel/db/links";
6
- import { assembleAnonymousPaths } from "./anonymous-paths.js";
6
+ import { assembleAnonymousPaths, assembleOptionalCustomerAuthPaths } from "./anonymous-paths.js";
7
7
  import { containerToServiceResolver, makeFrameworkLogger, wireWorkflowRuntime, } from "./app-workflows.js";
8
8
  import { composeAuthAugmentations } from "./auth-augmentation.js";
9
9
  import { expandApiBundles, isLazyApiBundle, } from "./bundle.js";
@@ -99,13 +99,14 @@ export function mountApp(config) {
99
99
  // Anonymous-access allow-list (ADR-0008): assembled from module/extension
100
100
  // `anonymous` declarations + bundle-declared absolute anonymous paths (e.g. a
101
101
  // payment-processor webhook) + any explicit `publicPaths` escape-hatch entries.
102
- // Used by both the auth middleware (skip auth / stamp customer actor) and the
102
+ // Used by both the auth middleware (skip auth / mark an explicit guest) and the
103
103
  // public-write rate-limit matcher below, so the two never diverge.
104
104
  const anonymousPaths = assembleAnonymousPaths(allModules, allExtensions, [
105
105
  ...(config.publicPaths ?? []),
106
106
  ...(expanded?.anonymousPaths ?? []),
107
107
  ...lazyPlugins.flatMap((plugin) => plugin.anonymous ?? []),
108
108
  ]);
109
+ const optionalCustomerAuthPaths = assembleOptionalCustomerAuthPaths(allModules, allExtensions);
109
110
  const clientAuthenticatedRoutes = assembleClientAuthenticatedRoutes(allModules);
110
111
  const composedAuth = composeAuthAugmentations(config.auth, allModules);
111
112
  // When the framework owns the bus, route subscriber-dispatch failures
@@ -263,6 +264,7 @@ export function mountApp(config) {
263
264
  anonymousPaths.push(...assembleAnonymousPaths(lazyExpanded.modules, lazyExpanded.extensions, [
264
265
  ...lazyExpanded.anonymousPaths,
265
266
  ]));
267
+ optionalCustomerAuthPaths.push(...assembleOptionalCustomerAuthPaths(lazyExpanded.modules, lazyExpanded.extensions));
266
268
  for (const mod of lazyExpanded.modules) {
267
269
  if (mod.module.service !== undefined) {
268
270
  container.register(mod.module.name, mod.module.service);
@@ -483,6 +485,7 @@ export function mountApp(config) {
483
485
  : config.db;
484
486
  const authOptions = {
485
487
  publicPaths: anonymousPaths,
488
+ optionalCustomerAuthPaths,
486
489
  clientAuthenticatedRoutes,
487
490
  basePath: config.basePath,
488
491
  auth: composedAuth,
@@ -1,4 +1,6 @@
1
1
  export { constantTimeEqual, generateNumericCode, randomBytesHex, sha256Base64Url, sha256Hex, unsignCookie, } from "./crypto.js";
2
+ export { type BusinessCustomerBuyerContext, type CustomerBuyerContext, type PersonalCustomerBuyerContext, requireBusinessCustomerBuyerContext, requireCustomerBuyerContext, requirePersonalCustomerBuyerContext, } from "./require-customer-buyer.js";
3
+ export { type CustomerIdentityContext, requireCustomerIdentityContext, } from "./require-customer-identity.js";
2
4
  export { requireUserId } from "./require-user.js";
3
5
  export type { SessionAuthContext } from "./session-jwt.js";
4
6
  export { extractBearerToken, verifySession } from "./session-jwt.js";
@@ -1,3 +1,5 @@
1
1
  export { constantTimeEqual, generateNumericCode, randomBytesHex, sha256Base64Url, sha256Hex, unsignCookie, } from "./crypto.js";
2
+ export { requireBusinessCustomerBuyerContext, requireCustomerBuyerContext, requirePersonalCustomerBuyerContext, } from "./require-customer-buyer.js";
3
+ export { requireCustomerIdentityContext, } from "./require-customer-identity.js";
2
4
  export { requireUserId } from "./require-user.js";
3
5
  export { extractBearerToken, verifySession } from "./session-jwt.js";
@@ -0,0 +1,27 @@
1
+ import type { Context } from "hono";
2
+ interface CustomerBuyerContextBase {
3
+ userId: string;
4
+ buyerAccountId: string;
5
+ }
6
+ export interface PersonalCustomerBuyerContext extends CustomerBuyerContextBase {
7
+ kind: "personal";
8
+ authOrganizationId: null;
9
+ relationshipOrganizationId: null;
10
+ relationshipPersonId: string | null;
11
+ membershipId: null;
12
+ membershipRole: null;
13
+ }
14
+ export interface BusinessCustomerBuyerContext extends CustomerBuyerContextBase {
15
+ kind: "business";
16
+ authOrganizationId: string;
17
+ relationshipOrganizationId: string;
18
+ relationshipPersonId: null;
19
+ membershipId: string;
20
+ membershipRole: string;
21
+ }
22
+ export type CustomerBuyerContext = PersonalCustomerBuyerContext | BusinessCustomerBuyerContext;
23
+ /** Requires the provider-neutral, request-revalidated storefront buyer context. */
24
+ export declare function requireCustomerBuyerContext(c: Context): CustomerBuyerContext;
25
+ export declare function requirePersonalCustomerBuyerContext(c: Context): PersonalCustomerBuyerContext;
26
+ export declare function requireBusinessCustomerBuyerContext(c: Context): BusinessCustomerBuyerContext;
27
+ export {};
@@ -0,0 +1,55 @@
1
+ import { ForbiddenApiError } from "../validation.js";
2
+ import { requireCustomerIdentityContext } from "./require-customer-identity.js";
3
+ /** Requires the provider-neutral, request-revalidated storefront buyer context. */
4
+ export function requireCustomerBuyerContext(c) {
5
+ const identity = requireCustomerIdentityContext(c);
6
+ const userId = identity.userId;
7
+ const buyerAccountId = c.get("buyerAccountId");
8
+ const kind = c.get("buyerAccountKind");
9
+ if (!buyerAccountId || (kind !== "personal" && kind !== "business")) {
10
+ throw new ForbiddenApiError("A customer buyer account must be selected");
11
+ }
12
+ if (kind === "personal") {
13
+ return {
14
+ userId,
15
+ buyerAccountId,
16
+ kind,
17
+ authOrganizationId: null,
18
+ relationshipOrganizationId: null,
19
+ relationshipPersonId: identity.relationshipPersonId,
20
+ membershipId: null,
21
+ membershipRole: null,
22
+ };
23
+ }
24
+ const authOrganizationId = c.get("authOrganizationId");
25
+ const relationshipOrganizationId = c.get("relationshipOrganizationId");
26
+ const membershipId = c.get("buyerMembershipId");
27
+ const membershipRole = c.get("buyerMembershipRole");
28
+ if (!authOrganizationId || !relationshipOrganizationId || !membershipId || !membershipRole) {
29
+ throw new ForbiddenApiError("The business buyer membership is no longer available");
30
+ }
31
+ return {
32
+ userId,
33
+ buyerAccountId,
34
+ kind,
35
+ authOrganizationId,
36
+ relationshipOrganizationId,
37
+ relationshipPersonId: null,
38
+ membershipId,
39
+ membershipRole,
40
+ };
41
+ }
42
+ export function requirePersonalCustomerBuyerContext(c) {
43
+ const buyer = requireCustomerBuyerContext(c);
44
+ if (buyer.kind !== "personal") {
45
+ throw new ForbiddenApiError("A personal buyer account is required");
46
+ }
47
+ return buyer;
48
+ }
49
+ export function requireBusinessCustomerBuyerContext(c) {
50
+ const buyer = requireCustomerBuyerContext(c);
51
+ if (buyer.kind !== "business") {
52
+ throw new ForbiddenApiError("A business buyer account is required");
53
+ }
54
+ return buyer;
55
+ }
@@ -0,0 +1,9 @@
1
+ import type { Context } from "hono";
2
+ export interface CustomerIdentityContext {
3
+ userId: string;
4
+ sessionId: string | null;
5
+ email: string | null;
6
+ relationshipPersonId: string | null;
7
+ }
8
+ /** Requires customer-realm identity without requiring a selected buyer account. */
9
+ export declare function requireCustomerIdentityContext(c: Context): CustomerIdentityContext;
@@ -0,0 +1,16 @@
1
+ import { UnauthorizedApiError } from "../validation.js";
2
+ /** Requires customer-realm identity without requiring a selected buyer account. */
3
+ export function requireCustomerIdentityContext(c) {
4
+ if (c.get("realm") !== "customer" || c.get("actor") !== "customer") {
5
+ throw new UnauthorizedApiError();
6
+ }
7
+ const userId = c.get("userId");
8
+ if (!userId)
9
+ throw new UnauthorizedApiError();
10
+ return {
11
+ userId,
12
+ sessionId: c.get("sessionId") ?? null,
13
+ email: c.get("email") ?? null,
14
+ relationshipPersonId: c.get("relationshipPersonId") ?? null,
15
+ };
16
+ }
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  export type { VoyantPermission } from "@voyant-travel/core";
2
- export { assembleAnonymousPaths } from "./anonymous-paths.js";
2
+ export { assembleAnonymousPaths, assembleOptionalCustomerAuthPaths } from "./anonymous-paths.js";
3
3
  export { mountApp } from "./app.js";
4
- export type { SessionAuthContext } from "./auth/index.js";
5
- export { constantTimeEqual, extractBearerToken, generateNumericCode, randomBytesHex, requireUserId, sha256Base64Url, sha256Hex, unsignCookie, verifySession, } from "./auth/index.js";
4
+ export type { BusinessCustomerBuyerContext, CustomerBuyerContext, CustomerIdentityContext, PersonalCustomerBuyerContext, SessionAuthContext, } from "./auth/index.js";
5
+ export { constantTimeEqual, extractBearerToken, generateNumericCode, randomBytesHex, requireBusinessCustomerBuyerContext, requireCustomerBuyerContext, requireCustomerIdentityContext, requirePersonalCustomerBuyerContext, requireUserId, sha256Base64Url, sha256Hex, unsignCookie, verifySession, } from "./auth/index.js";
6
6
  export type { ApiBundle, ApiBundleInput, ExpandedApiBundles, LazyApiBundle, } from "./bundle.js";
7
7
  export { defineApiBundle, defineLazyApiBundle, expandApiBundles, isLazyApiBundle, } from "./bundle.js";
8
8
  export { type CreateAppConfig, createApp } from "./create-app.js";
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- export { assembleAnonymousPaths } from "./anonymous-paths.js";
1
+ export { assembleAnonymousPaths, assembleOptionalCustomerAuthPaths } from "./anonymous-paths.js";
2
2
  export { mountApp } from "./app.js";
3
- export { constantTimeEqual, extractBearerToken, generateNumericCode, randomBytesHex, requireUserId, sha256Base64Url, sha256Hex, unsignCookie, verifySession, } from "./auth/index.js";
3
+ export { constantTimeEqual, extractBearerToken, generateNumericCode, randomBytesHex, requireBusinessCustomerBuyerContext, requireCustomerBuyerContext, requireCustomerIdentityContext, requirePersonalCustomerBuyerContext, requireUserId, sha256Base64Url, sha256Hex, unsignCookie, verifySession, } from "./auth/index.js";
4
4
  export { defineApiBundle, defineLazyApiBundle, expandApiBundles, isLazyApiBundle, } from "./bundle.js";
5
5
  export { createApp } from "./create-app.js";
6
6
  export { createAuthenticatedDocumentDownloadResolver, encodeStorageKeyPath, resolveStoredDocumentDownload, } from "./document-download.js";
@@ -3,6 +3,7 @@ import type { AbsoluteClientAuthenticatedRoute } from "../client-authenticated-r
3
3
  import { type DbSource, type VoyantAuthIntegration, type VoyantBindings, type VoyantVariables } from "../types.js";
4
4
  export declare function requireAuth<TBindings extends VoyantBindings>(dbSource: DbSource<TBindings>, opts?: {
5
5
  publicPaths?: string[];
6
+ optionalCustomerAuthPaths?: string[];
6
7
  clientAuthenticatedRoutes?: readonly AbsoluteClientAuthenticatedRoute[];
7
8
  basePath?: string;
8
9
  auth?: VoyantAuthIntegration<TBindings>;
@@ -117,8 +117,32 @@ function applyAuthContext(c, auth) {
117
117
  c.set("userId", auth.userId);
118
118
  if (auth.sessionId)
119
119
  c.set("sessionId", auth.sessionId);
120
+ if (auth.realm)
121
+ c.set("realm", auth.realm);
122
+ if (auth.isAnonymousRequest !== undefined) {
123
+ c.set("isAnonymousRequest", auth.isAnonymousRequest);
124
+ }
120
125
  if (auth.organizationId !== undefined)
121
126
  c.set("organizationId", auth.organizationId ?? undefined);
127
+ if (auth.buyerAccountId !== undefined)
128
+ c.set("buyerAccountId", auth.buyerAccountId ?? undefined);
129
+ if (auth.buyerAccountKind !== undefined)
130
+ c.set("buyerAccountKind", auth.buyerAccountKind);
131
+ if (auth.authOrganizationId !== undefined) {
132
+ c.set("authOrganizationId", auth.authOrganizationId ?? undefined);
133
+ }
134
+ if (auth.relationshipOrganizationId !== undefined) {
135
+ c.set("relationshipOrganizationId", auth.relationshipOrganizationId ?? undefined);
136
+ }
137
+ if (auth.relationshipPersonId !== undefined) {
138
+ c.set("relationshipPersonId", auth.relationshipPersonId ?? undefined);
139
+ }
140
+ if (auth.buyerMembershipId !== undefined) {
141
+ c.set("buyerMembershipId", auth.buyerMembershipId ?? undefined);
142
+ }
143
+ if (auth.buyerMembershipRole !== undefined) {
144
+ c.set("buyerMembershipRole", auth.buyerMembershipRole ?? undefined);
145
+ }
122
146
  if (auth.callerType)
123
147
  c.set("callerType", auth.callerType);
124
148
  if (auth.actor)
@@ -173,6 +197,7 @@ function matchesRequestRealm(path, auth) {
173
197
  }
174
198
  export function requireAuth(dbSource, opts) {
175
199
  const publicPaths = opts?.publicPaths ?? [];
200
+ const optionalCustomerAuthPaths = opts?.optionalCustomerAuthPaths ?? [];
176
201
  return async (c, next) => {
177
202
  if (c.req.method === "OPTIONS")
178
203
  return next();
@@ -188,10 +213,10 @@ export function requireAuth(dbSource, opts) {
188
213
  if (matchesClientAuthenticatedRoute(c.req.method, p, opts?.clientAuthenticatedRoutes ?? [])) {
189
214
  return next();
190
215
  }
191
- if (matchesPublicPath(p, publicPaths)) {
192
- if (p.startsWith("/v1/public/")) {
193
- c.set("actor", "customer");
194
- }
216
+ const isAnonymousPath = matchesPublicPath(p, publicPaths);
217
+ const isOptionalCustomerAuthPath = matchesPublicPath(p, optionalCustomerAuthPaths);
218
+ if (isAnonymousPath && !isOptionalCustomerAuthPath) {
219
+ c.set("isAnonymousRequest", true);
195
220
  return next();
196
221
  }
197
222
  const authHeader = c.req.header("authorization") || c.req.header("Authorization");
@@ -369,11 +394,16 @@ export function requireAuth(dbSource, opts) {
369
394
  : sessionRealm === "customer"
370
395
  ? customerSessionSecret
371
396
  : undefined;
372
- if (token && sessionSecret && sessionSecret.length >= 32 && token.includes(".")) {
397
+ if (token &&
398
+ sessionRealm &&
399
+ sessionSecret &&
400
+ sessionSecret.length >= 32 &&
401
+ token.includes(".")) {
373
402
  try {
374
403
  const sessionAuth = await verifySession(token, sessionSecret);
375
404
  applyAuthContext(c, {
376
405
  ...sessionAuth,
406
+ realm: sessionRealm,
377
407
  callerType: "session",
378
408
  actor: sessionRealm === "admin" ? "staff" : "customer",
379
409
  audience: sessionRealm === "admin" ? "staff" : "customer",
@@ -384,6 +414,10 @@ export function requireAuth(dbSource, opts) {
384
414
  // fall through
385
415
  }
386
416
  }
417
+ if (isAnonymousPath && isOptionalCustomerAuthPath) {
418
+ c.set("isAnonymousRequest", true);
419
+ return next();
420
+ }
387
421
  if (opts?.auth?.onUnauthorized) {
388
422
  const response = await opts.auth.onUnauthorized({
389
423
  request: c.req.raw,
@@ -99,6 +99,9 @@ export function requireActor(...args) {
99
99
  return async (c, next) => {
100
100
  if (c.req.method === "OPTIONS")
101
101
  return next();
102
+ if (c.get("isAnonymousRequest") && allowSet.has("customer") && !allowSet.has("staff")) {
103
+ return next();
104
+ }
102
105
  const pathname = normalizePathname(new URL(c.req.url).pathname, {
103
106
  basePath: options.basePath,
104
107
  });
package/dist/module.d.ts CHANGED
@@ -52,11 +52,13 @@ export interface ApiModule {
52
52
  * opens `/v1/public/customer-portal/contact-exists`). The framework assembles
53
53
  * the global anonymous allow-list from these declarations, so the
54
54
  * "reachable-without-auth" decision lives next to the route rather than in a
55
- * hand-maintained `publicPaths` list. Anonymous public requests are stamped
56
- * `actor: "customer"`. Only affects the public surface admin routes always
57
- * require a `staff` actor.
55
+ * hand-maintained `publicPaths` list. Anonymous public requests are marked as
56
+ * guests and do not receive a synthetic actor. Only affects the public
57
+ * surface — admin routes always require a `staff` actor.
58
58
  */
59
59
  anonymous?: boolean | readonly string[];
60
+ /** Anonymous paths that also attempt live customer-session resolution. */
61
+ optionalCustomerAuth?: boolean | readonly string[];
60
62
  /**
61
63
  * Concrete admin endpoints whose credential is validated by the route itself
62
64
  * instead of by the staff-session middleware. Each declaration is matched by
@@ -125,6 +127,8 @@ export interface ApiExtension {
125
127
  * to the extension's public mount.
126
128
  */
127
129
  anonymous?: boolean | readonly string[];
130
+ /** Anonymous paths that also attempt live customer-session resolution. */
131
+ optionalCustomerAuth?: boolean | readonly string[];
128
132
  /**
129
133
  * Absolute transactional path prefixes — same semantics as
130
134
  * {@link ApiModule.transactionalPaths}.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voyant-travel/hono",
3
- "version": "0.130.1",
3
+ "version": "0.131.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -130,17 +130,17 @@
130
130
  "drizzle-orm": "^0.45.2",
131
131
  "hono": "^4.12.27",
132
132
  "zod": "^4.4.3",
133
- "@voyant-travel/core": "^0.129.0",
134
- "@voyant-travel/db": "^0.114.15",
135
- "@voyant-travel/types": "^0.109.5",
133
+ "@voyant-travel/db": "^0.115.0",
134
+ "@voyant-travel/core": "^0.130.0",
135
+ "@voyant-travel/types": "^0.109.6",
136
136
  "@voyant-travel/utils": "^0.108.0",
137
- "@voyant-travel/workflows": "^0.122.8"
137
+ "@voyant-travel/workflows": "^0.122.9"
138
138
  },
139
139
  "devDependencies": {
140
140
  "typescript": "^6.0.3",
141
141
  "vitest": "^4.1.9",
142
142
  "@voyant-travel/voyant-typescript-config": "^0.1.0",
143
- "@voyant-travel/workflows-orchestrator": "^0.122.8"
143
+ "@voyant-travel/workflows-orchestrator": "^0.122.9"
144
144
  },
145
145
  "files": [
146
146
  "dist"