@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.
- package/dist/contracts.d.ts +28 -0
- package/dist/contracts.js +25 -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 +5 -1
- package/dist/index.js +5 -1
- 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 +9 -0
- package/dist/routes.js +43 -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/migrations/20260717150000_app_session_tokens.sql +18 -0
- package/migrations/meta/_journal.json +8 -1
- package/package.json +23 -3
|
@@ -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 {};
|