@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.
@@ -0,0 +1,82 @@
1
+ export declare const APP_SESSION_TOKEN_PREFIX = "vsess_";
2
+ /**
3
+ * HKDF context label under which admin session tokens are signed. Bumping the
4
+ * version suffix invalidates every outstanding token.
5
+ */
6
+ export declare const APP_SESSION_TOKEN_KEY_CONTEXT = "voyant:app-session-token:v1";
7
+ /** Default token lifetime — short enough to make replay low value. */
8
+ export declare const APP_SESSION_TOKEN_TTL_SECONDS = 120;
9
+ /** Stable issuer claim, distinguishing these from every other Voyant token. */
10
+ export declare const APP_SESSION_TOKEN_ISSUER = "voyant:apps:session-token";
11
+ /** The domain record a slot/detail surface is scoped to (null on list surfaces). */
12
+ export interface AppSessionTokenEntity {
13
+ type: string;
14
+ id: string;
15
+ }
16
+ /** The context the host binds a session token to at issuance time. */
17
+ export interface AppSessionTokenContext {
18
+ /** App registration id — becomes the token audience. */
19
+ appId: string;
20
+ installationId: string;
21
+ deploymentId: string;
22
+ /** The admin viewer the token acts on behalf of. */
23
+ viewerId: string;
24
+ entity?: AppSessionTokenEntity | null;
25
+ slot?: string | null;
26
+ }
27
+ /** The decoded, verified claim set. */
28
+ export interface AppSessionTokenClaims {
29
+ iss: string;
30
+ /** Audience — the app registration id the token was minted for. */
31
+ aud: string;
32
+ /** Subject — the admin viewer id. */
33
+ sub: string;
34
+ installationId: string;
35
+ deploymentId: string;
36
+ entity: AppSessionTokenEntity | null;
37
+ slot: string | null;
38
+ iat: number;
39
+ exp: number;
40
+ /** Unique token id; the exchange store enforces single use by this value. */
41
+ jti: string;
42
+ }
43
+ export interface SignAppSessionTokenOptions {
44
+ now?: () => Date;
45
+ ttlSeconds?: number;
46
+ /** Override the generated token id (tests/replay fixtures). */
47
+ jti?: string;
48
+ }
49
+ export interface SignedAppSessionToken {
50
+ token: string;
51
+ claims: AppSessionTokenClaims;
52
+ /** Expiry as epoch milliseconds, convenient for the protocol payload. */
53
+ expiresAtMs: number;
54
+ }
55
+ /**
56
+ * Mint a signed admin session token for the given context. The returned
57
+ * `claims.jti` should be recorded by the caller so the exchange can enforce
58
+ * single use.
59
+ */
60
+ export declare function signAppSessionToken(context: AppSessionTokenContext, secret: string, options?: SignAppSessionTokenOptions): SignedAppSessionToken;
61
+ export interface VerifyAppSessionTokenExpectations {
62
+ /** Reject unless `aud` equals this app id. */
63
+ audience?: string;
64
+ /** Reject unless `deploymentId` equals this deployment. */
65
+ deploymentId?: string;
66
+ now?: () => Date;
67
+ }
68
+ export type AppSessionTokenVerifyResult = {
69
+ ok: true;
70
+ claims: AppSessionTokenClaims;
71
+ } | {
72
+ ok: false;
73
+ reason: AppSessionTokenRejectReason;
74
+ };
75
+ export type AppSessionTokenRejectReason = "malformed" | "bad_signature" | "expired" | "wrong_issuer" | "audience_mismatch" | "deployment_mismatch";
76
+ /**
77
+ * Verify a session token's signature, issuer, expiry, and (when supplied)
78
+ * audience and deployment binding. Returns the decoded claims on success or a
79
+ * machine-readable rejection reason. This does NOT enforce single use — the
80
+ * exchange service consumes `claims.jti` to reject replay.
81
+ */
82
+ export declare function verifyAppSessionToken(token: string, secret: string, expectations?: VerifyAppSessionTokenExpectations): AppSessionTokenVerifyResult;
Binary file
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { APP_SESSION_TOKEN_PREFIX, signAppSessionToken, verifyAppSessionToken, } from "./session-token.js";
3
+ const secret = "test-deployment-session-secret-value-000000000000";
4
+ const context = {
5
+ appId: "app_1",
6
+ installationId: "apin_1",
7
+ deploymentId: "dep_1",
8
+ viewerId: "usr_1",
9
+ entity: { type: "booking", id: "book_1" },
10
+ slot: "booking.details.header",
11
+ };
12
+ const at = (iso) => () => new Date(iso);
13
+ describe("app session token", () => {
14
+ it("round-trips claims, binds context, and carries a unique id", () => {
15
+ const signed = signAppSessionToken(context, secret, { now: at("2026-07-17T10:00:00Z") });
16
+ expect(signed.token.startsWith(APP_SESSION_TOKEN_PREFIX)).toBe(true);
17
+ const result = verifyAppSessionToken(signed.token, secret, {
18
+ audience: "app_1",
19
+ deploymentId: "dep_1",
20
+ now: at("2026-07-17T10:01:00Z"),
21
+ });
22
+ expect(result.ok).toBe(true);
23
+ if (!result.ok)
24
+ return;
25
+ expect(result.claims.aud).toBe("app_1");
26
+ expect(result.claims.sub).toBe("usr_1");
27
+ expect(result.claims.installationId).toBe("apin_1");
28
+ expect(result.claims.entity).toEqual({ type: "booking", id: "book_1" });
29
+ expect(result.claims.slot).toBe("booking.details.header");
30
+ expect(result.claims.jti).toMatch(/^st_/);
31
+ });
32
+ it("mints a distinct jti per issuance", () => {
33
+ const a = signAppSessionToken(context, secret, { now: at("2026-07-17T10:00:00Z") });
34
+ const b = signAppSessionToken(context, secret, { now: at("2026-07-17T10:00:00Z") });
35
+ expect(a.claims.jti).not.toBe(b.claims.jti);
36
+ });
37
+ it("rejects a token past its short expiry", () => {
38
+ const signed = signAppSessionToken(context, secret, {
39
+ now: at("2026-07-17T10:00:00Z"),
40
+ ttlSeconds: 120,
41
+ });
42
+ const result = verifyAppSessionToken(signed.token, secret, {
43
+ now: at("2026-07-17T10:03:00Z"),
44
+ });
45
+ expect(result).toEqual({ ok: false, reason: "expired" });
46
+ });
47
+ it("fails closed on audience mismatch (confused deputy)", () => {
48
+ const signed = signAppSessionToken(context, secret, { now: at("2026-07-17T10:00:00Z") });
49
+ const result = verifyAppSessionToken(signed.token, secret, {
50
+ audience: "app_other",
51
+ now: at("2026-07-17T10:01:00Z"),
52
+ });
53
+ expect(result).toEqual({ ok: false, reason: "audience_mismatch" });
54
+ });
55
+ it("fails closed on deployment mismatch", () => {
56
+ const signed = signAppSessionToken(context, secret, { now: at("2026-07-17T10:00:00Z") });
57
+ const result = verifyAppSessionToken(signed.token, secret, {
58
+ deploymentId: "dep_other",
59
+ now: at("2026-07-17T10:01:00Z"),
60
+ });
61
+ expect(result).toEqual({ ok: false, reason: "deployment_mismatch" });
62
+ });
63
+ it("rejects a tampered signature", () => {
64
+ const signed = signAppSessionToken(context, secret, { now: at("2026-07-17T10:00:00Z") });
65
+ const tampered = `${signed.token.slice(0, -2)}xy`;
66
+ const result = verifyAppSessionToken(tampered, secret, { now: at("2026-07-17T10:01:00Z") });
67
+ expect(result.ok).toBe(false);
68
+ if (result.ok)
69
+ return;
70
+ expect(["bad_signature", "malformed"]).toContain(result.reason);
71
+ });
72
+ it("rejects a token signed with a different secret (context-separated key)", () => {
73
+ const signed = signAppSessionToken(context, secret, { now: at("2026-07-17T10:00:00Z") });
74
+ const result = verifyAppSessionToken(signed.token, `${secret}-rotated`, {
75
+ now: at("2026-07-17T10:01:00Z"),
76
+ });
77
+ expect(result).toEqual({ ok: false, reason: "bad_signature" });
78
+ });
79
+ it("rejects a non-session-token string", () => {
80
+ expect(verifyAppSessionToken("nope", secret)).toEqual({ ok: false, reason: "malformed" });
81
+ expect(verifyAppSessionToken("vsess_only.two", secret)).toEqual({
82
+ ok: false,
83
+ reason: "malformed",
84
+ });
85
+ });
86
+ it("normalizes a list-surface (no entity) context", () => {
87
+ const signed = signAppSessionToken({ ...context, entity: null, slot: null }, secret, {
88
+ now: at("2026-07-17T10:00:00Z"),
89
+ });
90
+ const result = verifyAppSessionToken(signed.token, secret, { now: at("2026-07-17T10:01:00Z") });
91
+ expect(result.ok).toBe(true);
92
+ if (!result.ok)
93
+ return;
94
+ expect(result.claims.entity).toBeNull();
95
+ expect(result.claims.slot).toBeNull();
96
+ });
97
+ });
package/dist/voyant.js CHANGED
@@ -1,5 +1,9 @@
1
1
  import { defineModule, requirePort } from "@voyant-travel/core/project";
2
2
  import { customFieldValueLifecycleRuntimePort, customFieldValueOperationsRuntimePort, } from "@voyant-travel/core/runtime-port";
3
+ const appsAdminRuntime = {
4
+ entry: "@voyant-travel/apps-react/admin",
5
+ export: "createSelectedAppsAdminExtension",
6
+ };
3
7
  const appInstallationLifecyclePayloadSchema = {
4
8
  type: "object",
5
9
  additionalProperties: false,
@@ -120,8 +124,11 @@ export const appsVoyantModule = defineModule({
120
124
  wildcard: "explicit-resource",
121
125
  },
122
126
  {
123
- id: "@voyant-travel/apps#access.webhooks",
124
- resource: "webhooks",
127
+ id: "@voyant-travel/apps#access.app-webhooks",
128
+ // Namespaced `app-webhooks` (not `webhooks`) to avoid a duplicate
129
+ // access-resource authority with @voyant-travel/workflow-runs, which
130
+ // owns the deployment `webhooks` resource.
131
+ resource: "app-webhooks",
125
132
  label: "App webhooks",
126
133
  description: "Read webhook health and request replay.",
127
134
  remoteSafe: true,
@@ -189,6 +196,49 @@ export const appsVoyantModule = defineModule({
189
196
  },
190
197
  ],
191
198
  },
199
+ admin: {
200
+ compositionOrder: 170,
201
+ runtime: appsAdminRuntime,
202
+ copy: [
203
+ {
204
+ id: "@voyant-travel/apps#admin.copy",
205
+ namespace: "apps.admin",
206
+ fallbackLocale: "en",
207
+ runtime: {
208
+ entry: "@voyant-travel/apps-react/i18n",
209
+ export: "appsUiMessageDefinitions",
210
+ },
211
+ },
212
+ ],
213
+ routes: [
214
+ {
215
+ id: "@voyant-travel/apps#admin.route.installed",
216
+ path: "/apps",
217
+ requiredScopes: ["apps:read"],
218
+ runtime: appsAdminRuntime,
219
+ },
220
+ {
221
+ id: "@voyant-travel/apps#admin.route.developer",
222
+ path: "/apps/developer",
223
+ requiredScopes: ["apps:write"],
224
+ runtime: appsAdminRuntime,
225
+ },
226
+ ],
227
+ nav: [
228
+ {
229
+ id: "@voyant-travel/apps#admin.nav.installed",
230
+ routeId: "@voyant-travel/apps#admin.route.installed",
231
+ label: { namespace: "apps.admin", key: "navigation.title" },
232
+ order: 170,
233
+ },
234
+ {
235
+ id: "@voyant-travel/apps#admin.nav.developer",
236
+ routeId: "@voyant-travel/apps#admin.route.developer",
237
+ label: { namespace: "apps.admin", key: "navigation.developerTitle" },
238
+ order: 171,
239
+ },
240
+ ],
241
+ },
192
242
  lifecycle: { uninstall: { default: "retain-data", purge: "not-supported" } },
193
243
  meta: {
194
244
  ownership: "package",
@@ -0,0 +1,18 @@
1
+ CREATE TABLE "app_session_tokens" (
2
+ "id" text PRIMARY KEY NOT NULL,
3
+ "installation_id" text NOT NULL,
4
+ "app_id" text NOT NULL,
5
+ "deployment_id" text NOT NULL,
6
+ "jti" text NOT NULL,
7
+ "viewer_id" text NOT NULL,
8
+ "entity_type" text,
9
+ "entity_id" text,
10
+ "slot" text,
11
+ "issued_at" timestamp with time zone DEFAULT now() NOT NULL,
12
+ "expires_at" timestamp with time zone NOT NULL,
13
+ "consumed_at" timestamp with time zone,
14
+ "consumed_by_actor_id" text
15
+ );--> statement-breakpoint
16
+ ALTER TABLE "app_session_tokens" ADD CONSTRAINT "app_session_tokens_installation_id_app_installations_id_fk" FOREIGN KEY ("installation_id") REFERENCES "public"."app_installations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
17
+ CREATE UNIQUE INDEX "uidx_app_session_tokens_jti" ON "app_session_tokens" USING btree ("jti");--> statement-breakpoint
18
+ CREATE INDEX "idx_app_session_tokens_installation" ON "app_session_tokens" USING btree ("installation_id","expires_at");
@@ -29,6 +29,13 @@
29
29
  "when": 1784298600000,
30
30
  "tag": "20260717143000_app_oauth_authorization",
31
31
  "breakpoints": true
32
+ },
33
+ {
34
+ "idx": 4,
35
+ "version": "7",
36
+ "when": 1784300400000,
37
+ "tag": "20260717150000_app_session_tokens",
38
+ "breakpoints": true
32
39
  }
33
40
  ]
34
- }
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voyant-travel/apps",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -49,11 +49,21 @@
49
49
  "import": "./dist/consent.js",
50
50
  "default": "./dist/consent.js"
51
51
  },
52
+ "./extension-resolution": {
53
+ "types": "./dist/extension-resolution.d.ts",
54
+ "import": "./dist/extension-resolution.js",
55
+ "default": "./dist/extension-resolution.js"
56
+ },
52
57
  "./ingestion": {
53
58
  "types": "./dist/ingestion.d.ts",
54
59
  "import": "./dist/ingestion.js",
55
60
  "default": "./dist/ingestion.js"
56
61
  },
62
+ "./locale-resolution": {
63
+ "types": "./dist/locale-resolution.d.ts",
64
+ "import": "./dist/locale-resolution.js",
65
+ "default": "./dist/locale-resolution.js"
66
+ },
57
67
  "./installation-service": {
58
68
  "types": "./dist/installation-service.d.ts",
59
69
  "import": "./dist/installation-service.js",
@@ -84,6 +94,16 @@
84
94
  "import": "./dist/service.js",
85
95
  "default": "./dist/service.js"
86
96
  },
97
+ "./session-token": {
98
+ "types": "./dist/session-token.d.ts",
99
+ "import": "./dist/session-token.js",
100
+ "default": "./dist/session-token.js"
101
+ },
102
+ "./session-token-service": {
103
+ "types": "./dist/session-token-service.d.ts",
104
+ "import": "./dist/session-token-service.js",
105
+ "default": "./dist/session-token-service.js"
106
+ },
87
107
  "./voyant": {
88
108
  "types": "./dist/voyant.d.ts",
89
109
  "import": "./dist/voyant.js",
@@ -95,8 +115,8 @@
95
115
  "drizzle-orm": "^0.45.2",
96
116
  "hono": "^4.12.27",
97
117
  "zod": "^4.4.3",
98
- "@voyant-travel/admin": "^0.126.2",
99
- "@voyant-travel/admin-extension-sdk": "^0.1.1",
118
+ "@voyant-travel/admin": "^0.127.0",
119
+ "@voyant-travel/admin-extension-sdk": "^0.2.0",
100
120
  "@voyant-travel/core": "^0.125.2",
101
121
  "@voyant-travel/custom-fields": "^0.2.1",
102
122
  "@voyant-travel/db": "^0.114.10",