@voyant-travel/apps 0.3.0 → 0.5.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.
Files changed (39) hide show
  1. package/dist/api-runtime.d.ts +15 -0
  2. package/dist/api-runtime.js +15 -1
  3. package/dist/app-api-contracts.d.ts +51 -0
  4. package/dist/app-api-contracts.js +50 -0
  5. package/dist/app-api-routes.d.ts +28 -0
  6. package/dist/app-api-routes.js +141 -0
  7. package/dist/app-api-service.d.ts +1263 -0
  8. package/dist/app-api-service.js +244 -0
  9. package/dist/app-api-service.test.d.ts +1 -0
  10. package/dist/app-api-service.test.js +109 -0
  11. package/dist/consent.js +16 -1
  12. package/dist/consent.test.js +24 -0
  13. package/dist/contracts.d.ts +31 -3
  14. package/dist/contracts.js +25 -0
  15. package/dist/extension-resolution.d.ts +72 -0
  16. package/dist/extension-resolution.js +159 -0
  17. package/dist/extension-resolution.test.d.ts +1 -0
  18. package/dist/extension-resolution.test.js +111 -0
  19. package/dist/index.d.ts +8 -1
  20. package/dist/index.js +8 -1
  21. package/dist/installation-service.d.ts +1 -1
  22. package/dist/locale-resolution.d.ts +59 -0
  23. package/dist/locale-resolution.js +113 -0
  24. package/dist/locale-resolution.test.d.ts +1 -0
  25. package/dist/locale-resolution.test.js +48 -0
  26. package/dist/routes.d.ts +9 -0
  27. package/dist/routes.js +43 -2
  28. package/dist/schema.d.ts +237 -0
  29. package/dist/schema.js +27 -0
  30. package/dist/session-token-service.d.ts +47 -0
  31. package/dist/session-token-service.js +127 -0
  32. package/dist/session-token.d.ts +82 -0
  33. package/dist/session-token.js +0 -0
  34. package/dist/session-token.test.d.ts +1 -0
  35. package/dist/session-token.test.js +97 -0
  36. package/dist/voyant.js +87 -1
  37. package/migrations/20260717150000_app_session_tokens.sql +18 -0
  38. package/migrations/meta/_journal.json +8 -1
  39. package/package.json +42 -7
package/dist/schema.d.ts CHANGED
@@ -2928,6 +2928,241 @@ export declare const appAuditEvents: import("drizzle-orm/pg-core").PgTableWithCo
2928
2928
  };
2929
2929
  dialect: "pg";
2930
2930
  }>;
2931
+ /**
2932
+ * Issued admin session tokens for iframe extensions. Each row is the durable
2933
+ * record of one short-lived, single-use session token: it makes issuance and
2934
+ * exchange auditable and provides the replay guard — a token's `jti` may be
2935
+ * consumed at most once (unique index + a conditional update on `consumedAt`).
2936
+ * The token bytes are never stored; only the signed claim identity.
2937
+ */
2938
+ export declare const appSessionTokens: import("drizzle-orm/pg-core").PgTableWithColumns<{
2939
+ name: "app_session_tokens";
2940
+ schema: undefined;
2941
+ columns: {
2942
+ id: import("drizzle-orm/pg-core").PgColumn<{
2943
+ name: string;
2944
+ tableName: "app_session_tokens";
2945
+ dataType: "string";
2946
+ columnType: "PgText";
2947
+ data: string;
2948
+ driverParam: string;
2949
+ notNull: true;
2950
+ hasDefault: true;
2951
+ isPrimaryKey: true;
2952
+ isAutoincrement: false;
2953
+ hasRuntimeDefault: true;
2954
+ enumValues: [string, ...string[]];
2955
+ baseColumn: never;
2956
+ identity: undefined;
2957
+ generated: undefined;
2958
+ }, {}, {}>;
2959
+ installationId: import("drizzle-orm/pg-core").PgColumn<{
2960
+ name: "installation_id";
2961
+ tableName: "app_session_tokens";
2962
+ dataType: "string";
2963
+ columnType: "PgText";
2964
+ data: string;
2965
+ driverParam: string;
2966
+ notNull: true;
2967
+ hasDefault: false;
2968
+ isPrimaryKey: false;
2969
+ isAutoincrement: false;
2970
+ hasRuntimeDefault: false;
2971
+ enumValues: [string, ...string[]];
2972
+ baseColumn: never;
2973
+ identity: undefined;
2974
+ generated: undefined;
2975
+ }, {}, {}>;
2976
+ appId: import("drizzle-orm/pg-core").PgColumn<{
2977
+ name: "app_id";
2978
+ tableName: "app_session_tokens";
2979
+ dataType: "string";
2980
+ columnType: "PgText";
2981
+ data: string;
2982
+ driverParam: string;
2983
+ notNull: true;
2984
+ hasDefault: false;
2985
+ isPrimaryKey: false;
2986
+ isAutoincrement: false;
2987
+ hasRuntimeDefault: false;
2988
+ enumValues: [string, ...string[]];
2989
+ baseColumn: never;
2990
+ identity: undefined;
2991
+ generated: undefined;
2992
+ }, {}, {}>;
2993
+ deploymentId: import("drizzle-orm/pg-core").PgColumn<{
2994
+ name: "deployment_id";
2995
+ tableName: "app_session_tokens";
2996
+ dataType: "string";
2997
+ columnType: "PgText";
2998
+ data: string;
2999
+ driverParam: string;
3000
+ notNull: true;
3001
+ hasDefault: false;
3002
+ isPrimaryKey: false;
3003
+ isAutoincrement: false;
3004
+ hasRuntimeDefault: false;
3005
+ enumValues: [string, ...string[]];
3006
+ baseColumn: never;
3007
+ identity: undefined;
3008
+ generated: undefined;
3009
+ }, {}, {}>;
3010
+ jti: import("drizzle-orm/pg-core").PgColumn<{
3011
+ name: "jti";
3012
+ tableName: "app_session_tokens";
3013
+ dataType: "string";
3014
+ columnType: "PgText";
3015
+ data: string;
3016
+ driverParam: string;
3017
+ notNull: true;
3018
+ hasDefault: false;
3019
+ isPrimaryKey: false;
3020
+ isAutoincrement: false;
3021
+ hasRuntimeDefault: false;
3022
+ enumValues: [string, ...string[]];
3023
+ baseColumn: never;
3024
+ identity: undefined;
3025
+ generated: undefined;
3026
+ }, {}, {}>;
3027
+ viewerId: import("drizzle-orm/pg-core").PgColumn<{
3028
+ name: "viewer_id";
3029
+ tableName: "app_session_tokens";
3030
+ dataType: "string";
3031
+ columnType: "PgText";
3032
+ data: string;
3033
+ driverParam: string;
3034
+ notNull: true;
3035
+ hasDefault: false;
3036
+ isPrimaryKey: false;
3037
+ isAutoincrement: false;
3038
+ hasRuntimeDefault: false;
3039
+ enumValues: [string, ...string[]];
3040
+ baseColumn: never;
3041
+ identity: undefined;
3042
+ generated: undefined;
3043
+ }, {}, {}>;
3044
+ entityType: import("drizzle-orm/pg-core").PgColumn<{
3045
+ name: "entity_type";
3046
+ tableName: "app_session_tokens";
3047
+ dataType: "string";
3048
+ columnType: "PgText";
3049
+ data: string;
3050
+ driverParam: string;
3051
+ notNull: false;
3052
+ hasDefault: false;
3053
+ isPrimaryKey: false;
3054
+ isAutoincrement: false;
3055
+ hasRuntimeDefault: false;
3056
+ enumValues: [string, ...string[]];
3057
+ baseColumn: never;
3058
+ identity: undefined;
3059
+ generated: undefined;
3060
+ }, {}, {}>;
3061
+ entityId: import("drizzle-orm/pg-core").PgColumn<{
3062
+ name: "entity_id";
3063
+ tableName: "app_session_tokens";
3064
+ dataType: "string";
3065
+ columnType: "PgText";
3066
+ data: string;
3067
+ driverParam: string;
3068
+ notNull: false;
3069
+ hasDefault: false;
3070
+ isPrimaryKey: false;
3071
+ isAutoincrement: false;
3072
+ hasRuntimeDefault: false;
3073
+ enumValues: [string, ...string[]];
3074
+ baseColumn: never;
3075
+ identity: undefined;
3076
+ generated: undefined;
3077
+ }, {}, {}>;
3078
+ slot: import("drizzle-orm/pg-core").PgColumn<{
3079
+ name: "slot";
3080
+ tableName: "app_session_tokens";
3081
+ dataType: "string";
3082
+ columnType: "PgText";
3083
+ data: string;
3084
+ driverParam: string;
3085
+ notNull: false;
3086
+ hasDefault: false;
3087
+ isPrimaryKey: false;
3088
+ isAutoincrement: false;
3089
+ hasRuntimeDefault: false;
3090
+ enumValues: [string, ...string[]];
3091
+ baseColumn: never;
3092
+ identity: undefined;
3093
+ generated: undefined;
3094
+ }, {}, {}>;
3095
+ issuedAt: import("drizzle-orm/pg-core").PgColumn<{
3096
+ name: "issued_at";
3097
+ tableName: "app_session_tokens";
3098
+ dataType: "date";
3099
+ columnType: "PgTimestamp";
3100
+ data: Date;
3101
+ driverParam: string;
3102
+ notNull: true;
3103
+ hasDefault: true;
3104
+ isPrimaryKey: false;
3105
+ isAutoincrement: false;
3106
+ hasRuntimeDefault: false;
3107
+ enumValues: undefined;
3108
+ baseColumn: never;
3109
+ identity: undefined;
3110
+ generated: undefined;
3111
+ }, {}, {}>;
3112
+ expiresAt: import("drizzle-orm/pg-core").PgColumn<{
3113
+ name: "expires_at";
3114
+ tableName: "app_session_tokens";
3115
+ dataType: "date";
3116
+ columnType: "PgTimestamp";
3117
+ data: Date;
3118
+ driverParam: string;
3119
+ notNull: true;
3120
+ hasDefault: false;
3121
+ isPrimaryKey: false;
3122
+ isAutoincrement: false;
3123
+ hasRuntimeDefault: false;
3124
+ enumValues: undefined;
3125
+ baseColumn: never;
3126
+ identity: undefined;
3127
+ generated: undefined;
3128
+ }, {}, {}>;
3129
+ consumedAt: import("drizzle-orm/pg-core").PgColumn<{
3130
+ name: "consumed_at";
3131
+ tableName: "app_session_tokens";
3132
+ dataType: "date";
3133
+ columnType: "PgTimestamp";
3134
+ data: Date;
3135
+ driverParam: string;
3136
+ notNull: false;
3137
+ hasDefault: false;
3138
+ isPrimaryKey: false;
3139
+ isAutoincrement: false;
3140
+ hasRuntimeDefault: false;
3141
+ enumValues: undefined;
3142
+ baseColumn: never;
3143
+ identity: undefined;
3144
+ generated: undefined;
3145
+ }, {}, {}>;
3146
+ consumedByActorId: import("drizzle-orm/pg-core").PgColumn<{
3147
+ name: "consumed_by_actor_id";
3148
+ tableName: "app_session_tokens";
3149
+ dataType: "string";
3150
+ columnType: "PgText";
3151
+ data: string;
3152
+ driverParam: string;
3153
+ notNull: false;
3154
+ hasDefault: false;
3155
+ isPrimaryKey: false;
3156
+ isAutoincrement: false;
3157
+ hasRuntimeDefault: false;
3158
+ enumValues: [string, ...string[]];
3159
+ baseColumn: never;
3160
+ identity: undefined;
3161
+ generated: undefined;
3162
+ }, {}, {}>;
3163
+ };
3164
+ dialect: "pg";
3165
+ }>;
2931
3166
  export type AppRegistration = typeof apps.$inferSelect;
2932
3167
  export type NewAppRegistration = typeof apps.$inferInsert;
2933
3168
  export type AppRelease = typeof appReleases.$inferSelect;
@@ -2935,3 +3170,5 @@ export type NewAppRelease = typeof appReleases.$inferInsert;
2935
3170
  export type AppInstallation = typeof appInstallations.$inferSelect;
2936
3171
  export type NewAppInstallation = typeof appInstallations.$inferInsert;
2937
3172
  export type AppAccessCredential = typeof appAccessCredentials.$inferSelect;
3173
+ export type AppSessionTokenRow = typeof appSessionTokens.$inferSelect;
3174
+ export type NewAppSessionTokenRow = typeof appSessionTokens.$inferInsert;
package/dist/schema.js CHANGED
@@ -340,3 +340,30 @@ export const appAuditEvents = pgTable("app_audit_events", {
340
340
  index("idx_app_audit_events_installation").on(table.installationId, table.createdAt),
341
341
  index("idx_app_audit_events_app").on(table.appId, table.deploymentId, table.createdAt),
342
342
  ]);
343
+ /**
344
+ * Issued admin session tokens for iframe extensions. Each row is the durable
345
+ * record of one short-lived, single-use session token: it makes issuance and
346
+ * exchange auditable and provides the replay guard — a token's `jti` may be
347
+ * consumed at most once (unique index + a conditional update on `consumedAt`).
348
+ * The token bytes are never stored; only the signed claim identity.
349
+ */
350
+ export const appSessionTokens = pgTable("app_session_tokens", {
351
+ id: typeId("app_session_tokens"),
352
+ installationId: text("installation_id")
353
+ .notNull()
354
+ .references(() => appInstallations.id, { onDelete: "cascade" }),
355
+ appId: text("app_id").notNull(),
356
+ deploymentId: text("deployment_id").notNull(),
357
+ jti: text("jti").notNull(),
358
+ viewerId: text("viewer_id").notNull(),
359
+ entityType: text("entity_type"),
360
+ entityId: text("entity_id"),
361
+ slot: text("slot"),
362
+ issuedAt: timestamp("issued_at", { withTimezone: true }).notNull().defaultNow(),
363
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
364
+ consumedAt: timestamp("consumed_at", { withTimezone: true }),
365
+ consumedByActorId: text("consumed_by_actor_id"),
366
+ }, (table) => [
367
+ uniqueIndex("uidx_app_session_tokens_jti").on(table.jti),
368
+ index("idx_app_session_tokens_installation").on(table.installationId, table.expiresAt),
369
+ ]);
@@ -0,0 +1,47 @@
1
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
2
+ import type { createAppOAuthService } from "./oauth-service.js";
3
+ import { type AppSessionTokenEntity } from "./session-token.js";
4
+ type AppOAuthService = ReturnType<typeof createAppOAuthService>;
5
+ export interface AppSessionTokenServiceOptions {
6
+ /** Root secret; the signing key is HKDF-derived from it under a private context. */
7
+ secret: string;
8
+ /** The deployment audience the tokens are bound to. */
9
+ deploymentId: string;
10
+ /** OAuth service supplying the online actor-token-exchange primitive. */
11
+ oauth: AppOAuthService;
12
+ ttlSeconds?: number;
13
+ now?: () => Date;
14
+ }
15
+ export interface IssueAppSessionTokenInput {
16
+ installationId: string;
17
+ viewerId: string;
18
+ entity?: AppSessionTokenEntity | null;
19
+ slot?: string | null;
20
+ }
21
+ export interface IssuedAppSessionToken {
22
+ token: string;
23
+ tokenId: string;
24
+ expiresAtMs: number;
25
+ }
26
+ export interface ExchangeAppSessionTokenInput {
27
+ token: string;
28
+ /** Authenticating app client; must match the token audience (confused-deputy guard). */
29
+ clientId: string;
30
+ clientSecret?: string;
31
+ /** The viewer's current effective scopes; the online token is bounded by these. */
32
+ viewerScopes: readonly string[];
33
+ /** Optional further contextual narrowing (defaults to the app grants). */
34
+ contextualScopes?: readonly string[];
35
+ }
36
+ export declare function createAppSessionTokenService(options: AppSessionTokenServiceOptions): {
37
+ issue: (db: PostgresJsDatabase, input: IssueAppSessionTokenInput) => Promise<IssuedAppSessionToken>;
38
+ exchange: (db: PostgresJsDatabase, input: ExchangeAppSessionTokenInput) => Promise<{
39
+ refreshToken?: string | undefined;
40
+ accessToken: string;
41
+ tokenType: "Bearer";
42
+ expiresIn: number;
43
+ scope: string;
44
+ tokenMode: "offline" | "online";
45
+ }>;
46
+ };
47
+ export {};
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Issuance, replay guard, and backend exchange for admin session tokens.
3
+ *
4
+ * The crypto lives in `session-token.ts`; this module binds it to the
5
+ * installation aggregate:
6
+ * - {@link issueAppSessionToken} mints a token for an active installation and
7
+ * records the `jti` so it can be consumed exactly once.
8
+ * - {@link exchangeAppSessionToken} verifies a token an app backend presents,
9
+ * consumes its `jti` (rejecting replay), and swaps it for online actor access
10
+ * via the existing OAuth actor-token-exchange primitive — bounded by the
11
+ * intersection of the app's grants and the viewer's scopes.
12
+ *
13
+ * Both paths write `app_audit_events` so issuance and exchange are auditable.
14
+ */
15
+ import { ApiHttpError } from "@voyant-travel/hono";
16
+ import { and, eq, isNull } from "drizzle-orm";
17
+ import { appAuditEvents, appInstallations, appSessionTokens, } from "./schema.js";
18
+ import { signAppSessionToken, verifyAppSessionToken, } from "./session-token.js";
19
+ export function createAppSessionTokenService(options) {
20
+ const now = () => options.now?.() ?? new Date();
21
+ async function issue(db, input) {
22
+ const installation = await requireActiveInstallation(db, input.installationId);
23
+ const context = {
24
+ appId: installation.appId,
25
+ installationId: installation.id,
26
+ deploymentId: installation.deploymentId,
27
+ viewerId: input.viewerId,
28
+ entity: input.entity ?? null,
29
+ slot: input.slot ?? null,
30
+ };
31
+ const signed = signAppSessionToken(context, options.secret, {
32
+ now,
33
+ ttlSeconds: options.ttlSeconds,
34
+ });
35
+ await db.insert(appSessionTokens).values({
36
+ installationId: installation.id,
37
+ appId: installation.appId,
38
+ deploymentId: installation.deploymentId,
39
+ jti: signed.claims.jti,
40
+ viewerId: input.viewerId,
41
+ entityType: signed.claims.entity?.type ?? null,
42
+ entityId: signed.claims.entity?.id ?? null,
43
+ slot: signed.claims.slot,
44
+ expiresAt: new Date(signed.claims.exp * 1000),
45
+ });
46
+ await audit(db, installation, input.viewerId, "session-token.issued", {
47
+ jti: signed.claims.jti,
48
+ slot: signed.claims.slot,
49
+ entityType: signed.claims.entity?.type ?? null,
50
+ });
51
+ return { token: signed.token, tokenId: signed.claims.jti, expiresAtMs: signed.expiresAtMs };
52
+ }
53
+ async function exchange(db, input) {
54
+ const verified = verifyAppSessionToken(input.token, options.secret, {
55
+ deploymentId: options.deploymentId,
56
+ audience: input.clientId,
57
+ now,
58
+ });
59
+ if (!verified.ok) {
60
+ throw new ApiHttpError("Session token rejected", {
61
+ status: 401,
62
+ code: `app_session_token_${verified.reason}`,
63
+ });
64
+ }
65
+ const { claims } = verified;
66
+ // Single-use: consume the jti atomically. A row that is missing or already
67
+ // consumed is a replay and is refused before any credential is minted.
68
+ const consumed = await db
69
+ .update(appSessionTokens)
70
+ .set({ consumedAt: now(), consumedByActorId: claims.sub })
71
+ .where(and(eq(appSessionTokens.jti, claims.jti), isNull(appSessionTokens.consumedAt)))
72
+ .returning({ id: appSessionTokens.id });
73
+ if (consumed.length === 0) {
74
+ throw new ApiHttpError("Session token has already been used", {
75
+ status: 401,
76
+ code: "app_session_token_replayed",
77
+ });
78
+ }
79
+ const installation = await requireActiveInstallation(db, claims.installationId);
80
+ const tokens = await options.oauth.token(db, {
81
+ grantType: "urn:voyant:params:oauth:grant-type:actor-token-exchange",
82
+ installationId: installation.id,
83
+ viewerId: claims.sub,
84
+ viewerScopes: input.viewerScopes,
85
+ contextualScopes: input.contextualScopes,
86
+ clientId: input.clientId,
87
+ clientSecret: input.clientSecret,
88
+ });
89
+ await audit(db, installation, claims.sub, "session-token.exchanged", {
90
+ jti: claims.jti,
91
+ slot: claims.slot,
92
+ });
93
+ return tokens;
94
+ }
95
+ return { issue, exchange };
96
+ }
97
+ async function requireActiveInstallation(db, installationId) {
98
+ const [installation] = await db
99
+ .select()
100
+ .from(appInstallations)
101
+ .where(eq(appInstallations.id, installationId))
102
+ .limit(1);
103
+ if (!installation) {
104
+ throw new ApiHttpError("App installation not found", {
105
+ status: 404,
106
+ code: "app_installation_not_found",
107
+ });
108
+ }
109
+ if (installation.status !== "active") {
110
+ throw new ApiHttpError("App installation is not active", {
111
+ status: 403,
112
+ code: "app_installation_not_active",
113
+ });
114
+ }
115
+ return installation;
116
+ }
117
+ async function audit(db, installation, actorId, action, details) {
118
+ await db.insert(appAuditEvents).values({
119
+ installationId: installation.id,
120
+ appId: installation.appId,
121
+ deploymentId: installation.deploymentId,
122
+ actorId,
123
+ kind: "token",
124
+ action,
125
+ details,
126
+ });
127
+ }
@@ -0,0 +1,82 @@
1
+ export declare const APP_SESSION_TOKEN_PREFIX = "vsess_";
2
+ /**
3
+ * HKDF context label under which admin session tokens are signed. Bumping the
4
+ * version suffix invalidates every outstanding token.
5
+ */
6
+ export declare const APP_SESSION_TOKEN_KEY_CONTEXT = "voyant:app-session-token:v1";
7
+ /** Default token lifetime — short enough to make replay low value. */
8
+ export declare const APP_SESSION_TOKEN_TTL_SECONDS = 120;
9
+ /** Stable issuer claim, distinguishing these from every other Voyant token. */
10
+ export declare const APP_SESSION_TOKEN_ISSUER = "voyant:apps:session-token";
11
+ /** The domain record a slot/detail surface is scoped to (null on list surfaces). */
12
+ export interface AppSessionTokenEntity {
13
+ type: string;
14
+ id: string;
15
+ }
16
+ /** The context the host binds a session token to at issuance time. */
17
+ export interface AppSessionTokenContext {
18
+ /** App registration id — becomes the token audience. */
19
+ appId: string;
20
+ installationId: string;
21
+ deploymentId: string;
22
+ /** The admin viewer the token acts on behalf of. */
23
+ viewerId: string;
24
+ entity?: AppSessionTokenEntity | null;
25
+ slot?: string | null;
26
+ }
27
+ /** The decoded, verified claim set. */
28
+ export interface AppSessionTokenClaims {
29
+ iss: string;
30
+ /** Audience — the app registration id the token was minted for. */
31
+ aud: string;
32
+ /** Subject — the admin viewer id. */
33
+ sub: string;
34
+ installationId: string;
35
+ deploymentId: string;
36
+ entity: AppSessionTokenEntity | null;
37
+ slot: string | null;
38
+ iat: number;
39
+ exp: number;
40
+ /** Unique token id; the exchange store enforces single use by this value. */
41
+ jti: string;
42
+ }
43
+ export interface SignAppSessionTokenOptions {
44
+ now?: () => Date;
45
+ ttlSeconds?: number;
46
+ /** Override the generated token id (tests/replay fixtures). */
47
+ jti?: string;
48
+ }
49
+ export interface SignedAppSessionToken {
50
+ token: string;
51
+ claims: AppSessionTokenClaims;
52
+ /** Expiry as epoch milliseconds, convenient for the protocol payload. */
53
+ expiresAtMs: number;
54
+ }
55
+ /**
56
+ * Mint a signed admin session token for the given context. The returned
57
+ * `claims.jti` should be recorded by the caller so the exchange can enforce
58
+ * single use.
59
+ */
60
+ export declare function signAppSessionToken(context: AppSessionTokenContext, secret: string, options?: SignAppSessionTokenOptions): SignedAppSessionToken;
61
+ export interface VerifyAppSessionTokenExpectations {
62
+ /** Reject unless `aud` equals this app id. */
63
+ audience?: string;
64
+ /** Reject unless `deploymentId` equals this deployment. */
65
+ deploymentId?: string;
66
+ now?: () => Date;
67
+ }
68
+ export type AppSessionTokenVerifyResult = {
69
+ ok: true;
70
+ claims: AppSessionTokenClaims;
71
+ } | {
72
+ ok: false;
73
+ reason: AppSessionTokenRejectReason;
74
+ };
75
+ export type AppSessionTokenRejectReason = "malformed" | "bad_signature" | "expired" | "wrong_issuer" | "audience_mismatch" | "deployment_mismatch";
76
+ /**
77
+ * Verify a session token's signature, issuer, expiry, and (when supplied)
78
+ * audience and deployment binding. Returns the decoded claims on success or a
79
+ * machine-readable rejection reason. This does NOT enforce single use — the
80
+ * exchange service consumes `claims.jti` to reject replay.
81
+ */
82
+ export declare function verifyAppSessionToken(token: string, secret: string, expectations?: VerifyAppSessionTokenExpectations): AppSessionTokenVerifyResult;
Binary file
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { APP_SESSION_TOKEN_PREFIX, signAppSessionToken, verifyAppSessionToken, } from "./session-token.js";
3
+ const secret = "test-deployment-session-secret-value-000000000000";
4
+ const context = {
5
+ appId: "app_1",
6
+ installationId: "apin_1",
7
+ deploymentId: "dep_1",
8
+ viewerId: "usr_1",
9
+ entity: { type: "booking", id: "book_1" },
10
+ slot: "booking.details.header",
11
+ };
12
+ const at = (iso) => () => new Date(iso);
13
+ describe("app session token", () => {
14
+ it("round-trips claims, binds context, and carries a unique id", () => {
15
+ const signed = signAppSessionToken(context, secret, { now: at("2026-07-17T10:00:00Z") });
16
+ expect(signed.token.startsWith(APP_SESSION_TOKEN_PREFIX)).toBe(true);
17
+ const result = verifyAppSessionToken(signed.token, secret, {
18
+ audience: "app_1",
19
+ deploymentId: "dep_1",
20
+ now: at("2026-07-17T10:01:00Z"),
21
+ });
22
+ expect(result.ok).toBe(true);
23
+ if (!result.ok)
24
+ return;
25
+ expect(result.claims.aud).toBe("app_1");
26
+ expect(result.claims.sub).toBe("usr_1");
27
+ expect(result.claims.installationId).toBe("apin_1");
28
+ expect(result.claims.entity).toEqual({ type: "booking", id: "book_1" });
29
+ expect(result.claims.slot).toBe("booking.details.header");
30
+ expect(result.claims.jti).toMatch(/^st_/);
31
+ });
32
+ it("mints a distinct jti per issuance", () => {
33
+ const a = signAppSessionToken(context, secret, { now: at("2026-07-17T10:00:00Z") });
34
+ const b = signAppSessionToken(context, secret, { now: at("2026-07-17T10:00:00Z") });
35
+ expect(a.claims.jti).not.toBe(b.claims.jti);
36
+ });
37
+ it("rejects a token past its short expiry", () => {
38
+ const signed = signAppSessionToken(context, secret, {
39
+ now: at("2026-07-17T10:00:00Z"),
40
+ ttlSeconds: 120,
41
+ });
42
+ const result = verifyAppSessionToken(signed.token, secret, {
43
+ now: at("2026-07-17T10:03:00Z"),
44
+ });
45
+ expect(result).toEqual({ ok: false, reason: "expired" });
46
+ });
47
+ it("fails closed on audience mismatch (confused deputy)", () => {
48
+ const signed = signAppSessionToken(context, secret, { now: at("2026-07-17T10:00:00Z") });
49
+ const result = verifyAppSessionToken(signed.token, secret, {
50
+ audience: "app_other",
51
+ now: at("2026-07-17T10:01:00Z"),
52
+ });
53
+ expect(result).toEqual({ ok: false, reason: "audience_mismatch" });
54
+ });
55
+ it("fails closed on deployment mismatch", () => {
56
+ const signed = signAppSessionToken(context, secret, { now: at("2026-07-17T10:00:00Z") });
57
+ const result = verifyAppSessionToken(signed.token, secret, {
58
+ deploymentId: "dep_other",
59
+ now: at("2026-07-17T10:01:00Z"),
60
+ });
61
+ expect(result).toEqual({ ok: false, reason: "deployment_mismatch" });
62
+ });
63
+ it("rejects a tampered signature", () => {
64
+ const signed = signAppSessionToken(context, secret, { now: at("2026-07-17T10:00:00Z") });
65
+ const tampered = `${signed.token.slice(0, -2)}xy`;
66
+ const result = verifyAppSessionToken(tampered, secret, { now: at("2026-07-17T10:01:00Z") });
67
+ expect(result.ok).toBe(false);
68
+ if (result.ok)
69
+ return;
70
+ expect(["bad_signature", "malformed"]).toContain(result.reason);
71
+ });
72
+ it("rejects a token signed with a different secret (context-separated key)", () => {
73
+ const signed = signAppSessionToken(context, secret, { now: at("2026-07-17T10:00:00Z") });
74
+ const result = verifyAppSessionToken(signed.token, `${secret}-rotated`, {
75
+ now: at("2026-07-17T10:01:00Z"),
76
+ });
77
+ expect(result).toEqual({ ok: false, reason: "bad_signature" });
78
+ });
79
+ it("rejects a non-session-token string", () => {
80
+ expect(verifyAppSessionToken("nope", secret)).toEqual({ ok: false, reason: "malformed" });
81
+ expect(verifyAppSessionToken("vsess_only.two", secret)).toEqual({
82
+ ok: false,
83
+ reason: "malformed",
84
+ });
85
+ });
86
+ it("normalizes a list-surface (no entity) context", () => {
87
+ const signed = signAppSessionToken({ ...context, entity: null, slot: null }, secret, {
88
+ now: at("2026-07-17T10:00:00Z"),
89
+ });
90
+ const result = verifyAppSessionToken(signed.token, secret, { now: at("2026-07-17T10:01:00Z") });
91
+ expect(result.ok).toBe(true);
92
+ if (!result.ok)
93
+ return;
94
+ expect(result.claims.entity).toBeNull();
95
+ expect(result.claims.slot).toBeNull();
96
+ });
97
+ });