@voyant-travel/apps 0.4.0 → 0.6.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.
package/dist/routes.js CHANGED
@@ -1,16 +1,33 @@
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 { activateInstallationBodySchema, appCredentialRevocationSchema, appInstallationAuditQuerySchema, appInstallationListQuerySchema, appListQuerySchema, appOAuthAuthorizeQuerySchema, appOAuthTokenSchema, appSessionTokenExchangeSchema, appSessionTokenIssueSchema, appWebhookReplaySchema, createCustomAppRegistrationSchema, installAppSchema, lifecycleActionBodySchema, releaseManifestFetchSchema, releaseManifestUploadSchema, } from "./contracts.js";
5
+ import { listAppReleases, listInstallationAudit, listInstallationSummaries, loadInstallationDetail, } from "./installation-read-model.js";
6
+ import { createAppInstallationService } from "./installation-service.js";
5
7
  import { createAppOAuthService } from "./oauth-service.js";
6
8
  import { createAppsService } from "./service.js";
9
+ import { createAppSessionTokenService } from "./session-token-service.js";
7
10
  import { listAppWebhookHealth, replayAppWebhookDelivery } from "./webhook-delivery.js";
8
11
  const appIdParamSchema = z.object({ appId: z.string().min(1) });
9
12
  const installationIdParamSchema = z.object({ installationId: z.string().min(1) });
10
13
  export function createAppsAdminRoutes(options = {}) {
11
14
  const routes = new Hono();
12
15
  const service = createAppsService(options);
16
+ const installations = createAppInstallationService({
17
+ deploymentId: options.oauth?.deploymentId ?? options.deploymentId,
18
+ platformApiVersion: options.platformApiVersion,
19
+ eventBus: options.eventBus,
20
+ customFields: options.customFields,
21
+ });
13
22
  const oauth = options.oauth ? createAppOAuthService(options.oauth) : null;
23
+ const sessionTokens = oauth && options.oauth && options.sessionToken
24
+ ? createAppSessionTokenService({
25
+ secret: options.sessionToken.secret,
26
+ ttlSeconds: options.sessionToken.ttlSeconds,
27
+ deploymentId: options.oauth.deploymentId,
28
+ oauth,
29
+ })
30
+ : null;
14
31
  routes.get("/", async (c) => {
15
32
  const query = parseQuery(c, appListQuerySchema);
16
33
  return c.json(await service.list(c.get("db"), query), 200);
@@ -81,11 +98,127 @@ export function createAppsAdminRoutes(options = {}) {
81
98
  const result = await oauth.revokeInstallationCredentials(c.get("db"), body.installationId, body.actorId);
82
99
  return c.json(result, 200);
83
100
  });
101
+ // Staff-authenticated: the admin host requests a short-lived session token for
102
+ // the current viewer + entity/slot context. The viewer is taken from the
103
+ // authenticated session, never from the frame.
104
+ routes.post("/installations/:installationId/session-token", async (c) => {
105
+ if (!sessionTokens)
106
+ return c.json({ error: "App session tokens are not configured" }, 501);
107
+ const { installationId } = parseInstallationParams(c.req.param());
108
+ const viewerId = requireUserId(c);
109
+ const body = await parseJsonBody(c, appSessionTokenIssueSchema);
110
+ const issued = await sessionTokens.issue(c.get("db"), {
111
+ installationId,
112
+ viewerId,
113
+ entity: body.entity ?? null,
114
+ slot: body.slot ?? null,
115
+ });
116
+ return c.json({ data: issued }, 201);
117
+ });
118
+ // App-backend-facing: exchange a presented session token for online actor
119
+ // access. Client-authenticated; bounded by viewer ∩ app grants.
120
+ routes.post("/oauth/session-token/exchange", async (c) => {
121
+ if (!sessionTokens)
122
+ return c.json({ error: "App session tokens are not configured" }, 501);
123
+ const body = await parseJsonBody(c, appSessionTokenExchangeSchema);
124
+ const token = await sessionTokens.exchange(c.get("db"), {
125
+ token: body.session_token,
126
+ clientId: body.client_id,
127
+ clientSecret: body.client_secret,
128
+ viewerScopes: body.viewer_scopes,
129
+ contextualScopes: body.contextual_scopes,
130
+ });
131
+ return c.json(token, 200);
132
+ });
133
+ routes.post("/install", async (c) => {
134
+ const body = await parseJsonBody(c, installAppSchema);
135
+ // The deployment id is a runtime value (not known at graph-composition
136
+ // time), so resolve it per request: explicit body → runtime env →
137
+ // construction option. Without this the standard runtime mounts these
138
+ // routes with no deployment id and every install 400s (app_deployment_required).
139
+ const deploymentId = body.deploymentId ?? c.env?.VOYANT_CLOUD_DEPLOYMENT_ID?.trim() ?? options.deploymentId;
140
+ const result = await installations.install(c.get("db"), {
141
+ appId: body.appId,
142
+ releaseId: body.releaseId,
143
+ actorId: body.actorId,
144
+ grantedOptionalScopes: body.grantedOptionalScopes,
145
+ updatePolicy: body.updatePolicy,
146
+ deploymentId,
147
+ });
148
+ return c.json({ data: result }, 201);
149
+ });
150
+ routes.get("/installations", async (c) => {
151
+ const query = parseQuery(c, appInstallationListQuerySchema);
152
+ return c.json(await listInstallationSummaries(c.get("db"), query), 200);
153
+ });
154
+ routes.get("/installations/:installationId", async (c) => {
155
+ const { installationId } = parseInstallationParams(c.req.param());
156
+ const detail = await loadInstallationDetail(c.get("db"), installationId, {
157
+ platformApiVersion: options.platformApiVersion,
158
+ });
159
+ return detail
160
+ ? c.json({ data: detail }, 200)
161
+ : c.json({ error: "App installation not found" }, 404);
162
+ });
163
+ routes.get("/installations/:installationId/audit", async (c) => {
164
+ const { installationId } = parseInstallationParams(c.req.param());
165
+ const query = parseQuery(c, appInstallationAuditQuerySchema);
166
+ const data = await listInstallationAudit(c.get("db"), installationId, query.limit);
167
+ return c.json({ data }, 200);
168
+ });
169
+ routes.post("/installations/:installationId/pause", async (c) => {
170
+ const { installationId } = parseInstallationParams(c.req.param());
171
+ const body = await parseJsonBody(c, lifecycleActionBodySchema);
172
+ const result = await installations.pause(c.get("db"), { installationId, actorId: body.actorId });
173
+ return c.json({ data: result }, 200);
174
+ });
175
+ routes.post("/installations/:installationId/resume", async (c) => {
176
+ const { installationId } = parseInstallationParams(c.req.param());
177
+ const body = await parseJsonBody(c, lifecycleActionBodySchema);
178
+ const result = await installations.resume(c.get("db"), {
179
+ installationId,
180
+ actorId: body.actorId,
181
+ });
182
+ return c.json({ data: result }, 200);
183
+ });
184
+ routes.post("/installations/:installationId/uninstall", async (c) => {
185
+ const { installationId } = parseInstallationParams(c.req.param());
186
+ const body = await parseJsonBody(c, lifecycleActionBodySchema);
187
+ const result = await installations.uninstall(c.get("db"), {
188
+ installationId,
189
+ actorId: body.actorId,
190
+ });
191
+ return c.json({ data: result }, 200);
192
+ });
193
+ routes.post("/installations/:installationId/activate", async (c) => {
194
+ const { installationId } = parseInstallationParams(c.req.param());
195
+ const body = await parseJsonBody(c, activateInstallationBodySchema);
196
+ const result = await installations.upgrade(c.get("db"), {
197
+ installationId,
198
+ releaseId: body.releaseId,
199
+ actorId: body.actorId,
200
+ });
201
+ return c.json({ data: result }, 200);
202
+ });
203
+ routes.post("/installations/:installationId/purge-preview", async (c) => {
204
+ const { installationId } = parseInstallationParams(c.req.param());
205
+ const body = await parseJsonBody(c, lifecycleActionBodySchema);
206
+ const result = await installations.purgePreview(c.get("db"), {
207
+ installationId,
208
+ actorId: body.actorId,
209
+ });
210
+ return c.json({ data: result }, 200);
211
+ });
84
212
  routes.get("/:appId", async (c) => {
85
213
  const { appId } = parseParams(c.req.param());
86
214
  const app = await service.get(c.get("db"), appId);
87
215
  return app ? c.json({ data: app }, 200) : c.json({ error: "App not found" }, 404);
88
216
  });
217
+ routes.get("/:appId/releases", async (c) => {
218
+ const { appId } = parseParams(c.req.param());
219
+ const data = await listAppReleases(c.get("db"), appId);
220
+ return c.json({ data }, 200);
221
+ });
89
222
  routes.post("/:appId/releases", async (c) => {
90
223
  const { appId } = parseParams(c.req.param());
91
224
  const body = await parseJsonBody(c, releaseManifestUploadSchema);
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
+ }