@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/api-runtime.d.ts +3 -0
- package/dist/app-api-routes.js +1 -1
- package/dist/app-api-service.js +1 -1
- package/dist/contracts.d.ts +71 -0
- package/dist/contracts.js +66 -0
- package/dist/extension-resolution.d.ts +72 -0
- package/dist/extension-resolution.js +159 -0
- package/dist/extension-resolution.test.d.ts +1 -0
- package/dist/extension-resolution.test.js +111 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +6 -1
- package/dist/installation-read-model.d.ts +64 -0
- package/dist/installation-read-model.js +148 -0
- package/dist/installation-routes.test.d.ts +1 -0
- package/dist/installation-routes.test.js +161 -0
- package/dist/locale-resolution.d.ts +59 -0
- package/dist/locale-resolution.js +113 -0
- package/dist/locale-resolution.test.d.ts +1 -0
- package/dist/locale-resolution.test.js +48 -0
- package/dist/routes.d.ts +25 -0
- package/dist/routes.js +135 -2
- package/dist/schema.d.ts +237 -0
- package/dist/schema.js +27 -0
- package/dist/session-token-service.d.ts +47 -0
- package/dist/session-token-service.js +127 -0
- package/dist/session-token.d.ts +82 -0
- package/dist/session-token.js +0 -0
- package/dist/session-token.test.d.ts +1 -0
- package/dist/session-token.test.js +97 -0
- package/dist/voyant.js +52 -2
- package/migrations/20260717150000_app_session_tokens.sql +18 -0
- package/migrations/meta/_journal.json +8 -1
- package/package.json +23 -3
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
|
+
}
|