@voyant-travel/apps 0.4.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.
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createHostLabelResolver, resolveAppLocale, resolveTextDirection, } from "./locale-resolution.js";
3
+ describe("resolveAppLocale", () => {
4
+ const declaration = { defaultLocale: "en", supportedLocales: ["en", "pt", "pt-BR", "ar"] };
5
+ it("prefers an exact match", () => {
6
+ expect(resolveAppLocale("pt-BR", declaration).appLocale).toBe("pt-BR");
7
+ });
8
+ it("falls back to a language match when the exact tag is absent", () => {
9
+ expect(resolveAppLocale("pt-PT", declaration).appLocale).toBe("pt");
10
+ });
11
+ it("is case-insensitive on the requested tag", () => {
12
+ expect(resolveAppLocale("PT-br", declaration).appLocale).toBe("pt-BR");
13
+ });
14
+ it("falls back to the declared default when nothing matches", () => {
15
+ expect(resolveAppLocale("de-DE", declaration).appLocale).toBe("en");
16
+ });
17
+ it("resolves text direction from the resolved locale", () => {
18
+ expect(resolveAppLocale("ar", declaration).direction).toBe("rtl");
19
+ expect(resolveAppLocale("en", declaration).direction).toBe("ltr");
20
+ });
21
+ });
22
+ describe("resolveTextDirection", () => {
23
+ it("marks RTL languages", () => {
24
+ expect(resolveTextDirection("he-IL")).toBe("rtl");
25
+ expect(resolveTextDirection("fa")).toBe("rtl");
26
+ expect(resolveTextDirection("en-US")).toBe("ltr");
27
+ });
28
+ });
29
+ describe("createHostLabelResolver", () => {
30
+ const rows = [
31
+ { locale: "en", surface: "extension", messageKey: "title", text: "Reports" },
32
+ { locale: "en", surface: "navigation", messageKey: "nav", text: "Reports" },
33
+ { locale: "pt", surface: "extension", messageKey: "title", text: "Relatórios" },
34
+ ];
35
+ it("resolves in the app locale first", () => {
36
+ const resolver = createHostLabelResolver(rows, "pt", "en");
37
+ expect(resolver.resolve("title")).toBe("Relatórios");
38
+ });
39
+ it("falls back to the default locale deterministically", () => {
40
+ const resolver = createHostLabelResolver(rows, "pt", "en");
41
+ // "nav" is missing in pt; falls back to en.
42
+ expect(resolver.resolve("nav", ["navigation"])).toBe("Reports");
43
+ });
44
+ it("returns null when no surface carries the key", () => {
45
+ const resolver = createHostLabelResolver(rows, "en", "en");
46
+ expect(resolver.resolve("missing")).toBeNull();
47
+ });
48
+ });
package/dist/routes.d.ts CHANGED
@@ -12,6 +12,15 @@ export interface AppsAdminRouteOptions extends AppsServiceOptions {
12
12
  accessCatalog: AccessCatalog;
13
13
  deploymentId: string;
14
14
  };
15
+ /**
16
+ * Enables the iframe session-token broker. Requires {@link oauth} for the
17
+ * deployment audience and the actor-token-exchange primitive. `secret` is the
18
+ * root secret the signing key is HKDF-derived from.
19
+ */
20
+ sessionToken?: {
21
+ secret: string;
22
+ ttlSeconds?: number;
23
+ };
15
24
  }
16
25
  export declare function createAppsAdminRoutes(options?: AppsAdminRouteOptions): Hono<Env, import("hono/types").BlankSchema, "/">;
17
26
  export {};
package/dist/routes.js CHANGED
@@ -1,9 +1,10 @@
1
- import { parseJsonBody, parseQuery, RequestValidationError } from "@voyant-travel/hono";
1
+ import { parseJsonBody, parseQuery, RequestValidationError, requireUserId, } from "@voyant-travel/hono";
2
2
  import { Hono } from "hono";
3
3
  import { z } from "zod";
4
- import { appCredentialRevocationSchema, appListQuerySchema, appOAuthAuthorizeQuerySchema, appOAuthTokenSchema, appWebhookReplaySchema, createCustomAppRegistrationSchema, releaseManifestFetchSchema, releaseManifestUploadSchema, } from "./contracts.js";
4
+ import { appCredentialRevocationSchema, appListQuerySchema, appOAuthAuthorizeQuerySchema, appOAuthTokenSchema, appSessionTokenExchangeSchema, appSessionTokenIssueSchema, appWebhookReplaySchema, createCustomAppRegistrationSchema, releaseManifestFetchSchema, releaseManifestUploadSchema, } from "./contracts.js";
5
5
  import { createAppOAuthService } from "./oauth-service.js";
6
6
  import { createAppsService } from "./service.js";
7
+ import { createAppSessionTokenService } from "./session-token-service.js";
7
8
  import { listAppWebhookHealth, replayAppWebhookDelivery } from "./webhook-delivery.js";
8
9
  const appIdParamSchema = z.object({ appId: z.string().min(1) });
9
10
  const installationIdParamSchema = z.object({ installationId: z.string().min(1) });
@@ -11,6 +12,14 @@ export function createAppsAdminRoutes(options = {}) {
11
12
  const routes = new Hono();
12
13
  const service = createAppsService(options);
13
14
  const oauth = options.oauth ? createAppOAuthService(options.oauth) : null;
15
+ const sessionTokens = oauth && options.oauth && options.sessionToken
16
+ ? createAppSessionTokenService({
17
+ secret: options.sessionToken.secret,
18
+ ttlSeconds: options.sessionToken.ttlSeconds,
19
+ deploymentId: options.oauth.deploymentId,
20
+ oauth,
21
+ })
22
+ : null;
14
23
  routes.get("/", async (c) => {
15
24
  const query = parseQuery(c, appListQuerySchema);
16
25
  return c.json(await service.list(c.get("db"), query), 200);
@@ -81,6 +90,38 @@ export function createAppsAdminRoutes(options = {}) {
81
90
  const result = await oauth.revokeInstallationCredentials(c.get("db"), body.installationId, body.actorId);
82
91
  return c.json(result, 200);
83
92
  });
93
+ // Staff-authenticated: the admin host requests a short-lived session token for
94
+ // the current viewer + entity/slot context. The viewer is taken from the
95
+ // authenticated session, never from the frame.
96
+ routes.post("/installations/:installationId/session-token", async (c) => {
97
+ if (!sessionTokens)
98
+ return c.json({ error: "App session tokens are not configured" }, 501);
99
+ const { installationId } = parseInstallationParams(c.req.param());
100
+ const viewerId = requireUserId(c);
101
+ const body = await parseJsonBody(c, appSessionTokenIssueSchema);
102
+ const issued = await sessionTokens.issue(c.get("db"), {
103
+ installationId,
104
+ viewerId,
105
+ entity: body.entity ?? null,
106
+ slot: body.slot ?? null,
107
+ });
108
+ return c.json({ data: issued }, 201);
109
+ });
110
+ // App-backend-facing: exchange a presented session token for online actor
111
+ // access. Client-authenticated; bounded by viewer ∩ app grants.
112
+ routes.post("/oauth/session-token/exchange", async (c) => {
113
+ if (!sessionTokens)
114
+ return c.json({ error: "App session tokens are not configured" }, 501);
115
+ const body = await parseJsonBody(c, appSessionTokenExchangeSchema);
116
+ const token = await sessionTokens.exchange(c.get("db"), {
117
+ token: body.session_token,
118
+ clientId: body.client_id,
119
+ clientSecret: body.client_secret,
120
+ viewerScopes: body.viewer_scopes,
121
+ contextualScopes: body.contextual_scopes,
122
+ });
123
+ return c.json(token, 200);
124
+ });
84
125
  routes.get("/:appId", async (c) => {
85
126
  const { appId } = parseParams(c.req.param());
86
127
  const app = await service.get(c.get("db"), appId);
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 {};