@voyant-travel/apps 0.3.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.
Files changed (39) hide show
  1. package/dist/api-runtime.d.ts +15 -0
  2. package/dist/api-runtime.js +15 -1
  3. package/dist/app-api-contracts.d.ts +51 -0
  4. package/dist/app-api-contracts.js +50 -0
  5. package/dist/app-api-routes.d.ts +28 -0
  6. package/dist/app-api-routes.js +141 -0
  7. package/dist/app-api-service.d.ts +1263 -0
  8. package/dist/app-api-service.js +244 -0
  9. package/dist/app-api-service.test.d.ts +1 -0
  10. package/dist/app-api-service.test.js +109 -0
  11. package/dist/consent.js +16 -1
  12. package/dist/consent.test.js +24 -0
  13. package/dist/contracts.d.ts +31 -3
  14. package/dist/contracts.js +25 -0
  15. package/dist/extension-resolution.d.ts +72 -0
  16. package/dist/extension-resolution.js +159 -0
  17. package/dist/extension-resolution.test.d.ts +1 -0
  18. package/dist/extension-resolution.test.js +111 -0
  19. package/dist/index.d.ts +8 -1
  20. package/dist/index.js +8 -1
  21. package/dist/installation-service.d.ts +1 -1
  22. package/dist/locale-resolution.d.ts +59 -0
  23. package/dist/locale-resolution.js +113 -0
  24. package/dist/locale-resolution.test.d.ts +1 -0
  25. package/dist/locale-resolution.test.js +48 -0
  26. package/dist/routes.d.ts +9 -0
  27. package/dist/routes.js +43 -2
  28. package/dist/schema.d.ts +237 -0
  29. package/dist/schema.js +27 -0
  30. package/dist/session-token-service.d.ts +47 -0
  31. package/dist/session-token-service.js +127 -0
  32. package/dist/session-token.d.ts +82 -0
  33. package/dist/session-token.js +0 -0
  34. package/dist/session-token.test.d.ts +1 -0
  35. package/dist/session-token.test.js +97 -0
  36. package/dist/voyant.js +87 -1
  37. package/migrations/20260717150000_app_session_tokens.sql +18 -0
  38. package/migrations/meta/_journal.json +8 -1
  39. package/package.json +42 -7
@@ -0,0 +1,244 @@
1
+ import { createAppCustomFieldDefinitionOwner, createCustomFieldsService, } from "@voyant-travel/custom-fields";
2
+ import { ApiHttpError } from "@voyant-travel/hono";
3
+ import { and, asc, eq, sql } from "drizzle-orm";
4
+ import { assertActiveAppInstallationAccess } from "./access-boundary.js";
5
+ import { APP_API_VERSION } from "./app-api-contracts.js";
6
+ import { appAuditEvents, appGrants, appReleases, appWebhookSubscriptions } from "./schema.js";
7
+ const DEFAULT_RATE_LIMIT = { installationLimit: 120, appLimit: 600, windowMs: 60_000 };
8
+ const REMOTE_APP_NAMESPACE = /^\$app(?::[a-z][a-z0-9-]*)?$/;
9
+ const PHYSICAL_APP_NAMESPACE = /^app--/;
10
+ const buckets = new Map();
11
+ export function createAppApiService(options = {}) {
12
+ const now = () => options.now?.() ?? new Date();
13
+ const customFields = options.customFieldTargets &&
14
+ createCustomFieldsService(options.customFieldTargets, options.customFieldValueLifecycles ?? [], options.customFieldValueOperations ?? []);
15
+ async function introspect(db, context) {
16
+ const access = await requireAccess(db, context, ["app-installation:read"]);
17
+ const grants = await db
18
+ .select({
19
+ scope: appGrants.scope,
20
+ status: appGrants.status,
21
+ optional: appGrants.optional,
22
+ })
23
+ .from(appGrants)
24
+ .where(eq(appGrants.installationId, context.installationId))
25
+ .orderBy(appGrants.scope);
26
+ return {
27
+ data: {
28
+ installation: {
29
+ id: access.installation.id,
30
+ appId: access.installation.appId,
31
+ status: access.installation.status,
32
+ namespace: access.installation.namespace,
33
+ deploymentId: access.installation.deploymentId,
34
+ },
35
+ release: {
36
+ id: access.release.id,
37
+ version: access.release.releaseVersion,
38
+ apiCompatibility: access.release.apiCompatibility,
39
+ },
40
+ grants,
41
+ },
42
+ };
43
+ }
44
+ async function listEntities(db, context, entity, query) {
45
+ await requireAccess(db, context, [`${entity}:read`]);
46
+ const reader = options.entityReaders?.[entity];
47
+ if (!reader)
48
+ throw notSupported(`Entity "${entity}" is not supported by the App API.`);
49
+ return { data: await reader.list(db, query) };
50
+ }
51
+ async function listFinanceDocuments(db, context, query) {
52
+ await requireAccess(db, context, ["finance-documents:read"]);
53
+ if (!options.finance)
54
+ throw notSupported("Finance App API runtime is not configured.");
55
+ return { data: await options.finance.listDocuments(db, query) };
56
+ }
57
+ async function executeFinanceAction(db, context, input) {
58
+ await requireAccess(db, context, [`finance-actions:${input.action}`]);
59
+ if (!options.finance)
60
+ throw notSupported("Finance App API runtime is not configured.");
61
+ if (!input.approvalId) {
62
+ throw new ApiHttpError("Finance action requires action-ledger approval.", {
63
+ status: 403,
64
+ code: "app_api_finance_approval_required",
65
+ });
66
+ }
67
+ return { data: await options.finance.executeAction(db, input) };
68
+ }
69
+ async function listCustomFieldDefinitions(db, context, query) {
70
+ const access = await requireAccess(db, context, ["custom-field-definitions:read"]);
71
+ return customFieldsRequired().listForOwner(db, owner(access), query);
72
+ }
73
+ async function createCustomFieldDefinition(db, context, namespace, input) {
74
+ assertAppNamespaceAlias(namespace);
75
+ const access = await requireAccess(db, context, ["custom-field-definitions:write"]);
76
+ return { data: await customFieldsRequired().createForOwner(db, owner(access), input) };
77
+ }
78
+ async function updateCustomFieldDefinition(db, context, id, namespace, input) {
79
+ assertAppNamespaceAlias(namespace);
80
+ const access = await requireAccess(db, context, ["custom-field-definitions:write"]);
81
+ const data = await customFieldsRequired().updateForOwner(db, owner(access), id, input);
82
+ if (!data)
83
+ throw notFound("Custom-field definition not found.");
84
+ return { data };
85
+ }
86
+ async function listCustomFieldValues(db, context, query) {
87
+ await requireAccess(db, context, [`custom-field-values:${query.entityType ?? "read"}`]);
88
+ const access = await requireAccess(db, context, ["custom-field-values:read"]);
89
+ return customFieldsRequired().values.listForOwner(db, owner(access), query);
90
+ }
91
+ async function upsertCustomFieldValue(db, context, definitionId, input) {
92
+ await requireAccess(db, context, [
93
+ "custom-field-values:write",
94
+ `custom-field-values:${input.entityType}`,
95
+ ]);
96
+ const access = await requireAccess(db, context, []);
97
+ return {
98
+ data: await customFieldsRequired().values.upsertForOwner(db, owner(access), definitionId, input),
99
+ };
100
+ }
101
+ async function listWebhookHealth(db, context) {
102
+ await requireAccess(db, context, ["webhooks:read"]);
103
+ const data = await db
104
+ .select()
105
+ .from(appWebhookSubscriptions)
106
+ .where(eq(appWebhookSubscriptions.installationId, context.installationId))
107
+ .orderBy(asc(appWebhookSubscriptions.eventType));
108
+ return { data };
109
+ }
110
+ async function listAuditHistory(db, context, query) {
111
+ await requireAccess(db, context, ["app-audit:read"]);
112
+ const where = and(eq(appAuditEvents.appId, context.appId), eq(appAuditEvents.installationId, context.installationId));
113
+ const [data, count] = await Promise.all([
114
+ db.select().from(appAuditEvents).where(where).limit(query.limit).offset(query.offset),
115
+ db.select({ count: sql `count(*)::int` }).from(appAuditEvents).where(where),
116
+ ]);
117
+ return { data, total: count[0]?.count ?? 0, limit: query.limit, offset: query.offset };
118
+ }
119
+ return {
120
+ introspect,
121
+ listEntities,
122
+ listFinanceDocuments,
123
+ executeFinanceAction,
124
+ listCustomFieldDefinitions,
125
+ createCustomFieldDefinition,
126
+ updateCustomFieldDefinition,
127
+ listCustomFieldValues,
128
+ upsertCustomFieldValue,
129
+ listWebhookHealth,
130
+ listAuditHistory,
131
+ requireAccess,
132
+ enforceRateLimit,
133
+ };
134
+ async function requireAccess(db, context, requiredScopes) {
135
+ enforceRateLimit(context);
136
+ // The token's own scopes are the authority: for online tokens this is the
137
+ // narrowed viewer/context intersection minted at exchange, so an endpoint
138
+ // must not be reachable just because the installation was granted its
139
+ // scope. The installation-grant check below stays as defense in depth.
140
+ const tokenScopes = new Set(context.scopes);
141
+ const missingFromToken = requiredScopes.filter((scope) => !tokenScopes.has(scope));
142
+ if (missingFromToken.length > 0) {
143
+ throw new ApiHttpError("App token is missing required scopes", {
144
+ status: 403,
145
+ code: "app_api_token_scope_missing",
146
+ details: { scopes: [...missingFromToken].sort() },
147
+ });
148
+ }
149
+ const installation = await assertActiveAppInstallationAccess(db, {
150
+ installationId: context.installationId,
151
+ requiredScopes,
152
+ });
153
+ if (installation.appId !== context.appId || installation.releaseId !== context.releaseId) {
154
+ throw new ApiHttpError("App token does not match the active installation.", {
155
+ status: 403,
156
+ code: "app_api_token_installation_mismatch",
157
+ });
158
+ }
159
+ const [release] = await db
160
+ .select()
161
+ .from(appReleases)
162
+ .where(eq(appReleases.id, installation.releaseId))
163
+ .limit(1);
164
+ if (!release)
165
+ throw notFound("Installed app release not found.");
166
+ assertCompatibleVersion(context.apiVersion ?? APP_API_VERSION, release.apiCompatibility);
167
+ return { installation, release };
168
+ }
169
+ function enforceRateLimit(context) {
170
+ const policy = options.rateLimit ?? DEFAULT_RATE_LIMIT;
171
+ hit(`installation:${context.installationId}`, policy.installationLimit, policy.windowMs);
172
+ hit(`app:${context.appId}`, policy.appLimit, policy.windowMs);
173
+ }
174
+ function hit(key, limit, windowMs) {
175
+ const timestamp = now().getTime();
176
+ const bucket = buckets.get(key);
177
+ if (!bucket || bucket.resetAt <= timestamp) {
178
+ buckets.set(key, { count: 1, resetAt: timestamp + windowMs });
179
+ return;
180
+ }
181
+ if (bucket.count >= limit) {
182
+ throw new ApiHttpError("App API rate limit exceeded.", {
183
+ status: 429,
184
+ code: "app_api_rate_limited",
185
+ details: { key, resetAt: new Date(bucket.resetAt).toISOString() },
186
+ });
187
+ }
188
+ bucket.count += 1;
189
+ }
190
+ function owner(access) {
191
+ return createAppCustomFieldDefinitionOwner({
192
+ appId: access.installation.appId,
193
+ namespace: access.installation.namespace,
194
+ });
195
+ }
196
+ function customFieldsRequired() {
197
+ if (!customFields)
198
+ throw notSupported("Custom fields App API runtime is not configured.");
199
+ return customFields;
200
+ }
201
+ }
202
+ export function assertAppNamespaceAlias(namespace) {
203
+ if (!namespace || namespace === "$app")
204
+ return;
205
+ if (PHYSICAL_APP_NAMESPACE.test(namespace) || !REMOTE_APP_NAMESPACE.test(namespace)) {
206
+ throw new ApiHttpError("App APIs accept only the server-resolved $app namespace alias.", {
207
+ status: 400,
208
+ code: "app_api_invalid_custom_field_namespace",
209
+ });
210
+ }
211
+ }
212
+ export function assertCompatibleVersion(requested, range) {
213
+ if (requested < range.min || requested > range.max) {
214
+ throw new ApiHttpError("Requested App API version is outside the installed release range.", {
215
+ status: 426,
216
+ code: "app_api_version_out_of_range",
217
+ details: { requested, supported: range },
218
+ });
219
+ }
220
+ }
221
+ export async function withAppApiDeadline(promise, timeoutMs = 5_000) {
222
+ let timeout;
223
+ try {
224
+ return await Promise.race([
225
+ promise,
226
+ new Promise((_, reject) => {
227
+ timeout = setTimeout(() => reject(new ApiHttpError("App API request deadline exceeded.", {
228
+ status: 504,
229
+ code: "app_api_deadline_exceeded",
230
+ })), timeoutMs);
231
+ }),
232
+ ]);
233
+ }
234
+ finally {
235
+ if (timeout)
236
+ clearTimeout(timeout);
237
+ }
238
+ }
239
+ function notSupported(message) {
240
+ return new ApiHttpError(message, { status: 501, code: "app_api_not_configured" });
241
+ }
242
+ function notFound(message) {
243
+ return new ApiHttpError(message, { status: 404, code: "not_found" });
244
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,109 @@
1
+ import { ApiHttpError } from "@voyant-travel/hono";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { assertAppNamespaceAlias, assertCompatibleVersion, createAppApiService, } from "./app-api-service.js";
4
+ import { appGrants, appInstallations, appReleases } from "./schema.js";
5
+ function postgresStub(implementation) {
6
+ const db = Object.create(null);
7
+ Object.assign(db, implementation);
8
+ return db;
9
+ }
10
+ function createAccessDb(options) {
11
+ const installation = {
12
+ id: "inst_1",
13
+ appId: "app_1",
14
+ deploymentId: "dep_1",
15
+ releaseId: "rel_1",
16
+ status: options.status ?? "active",
17
+ namespace: "app--one",
18
+ };
19
+ const release = {
20
+ id: "rel_1",
21
+ appId: "app_1",
22
+ releaseVersion: "1.0.0",
23
+ apiCompatibility: options.releaseRange ?? { min: "2026-07-01", max: "2026-12-31" },
24
+ };
25
+ return postgresStub({
26
+ select: () => ({
27
+ from: (table) => ({
28
+ where: () => {
29
+ const rows = () => {
30
+ if (table === appInstallations)
31
+ return [installation];
32
+ if (table === appReleases)
33
+ return [release];
34
+ if (table === appGrants)
35
+ return (options.scopes ?? []).map((scope) => ({ scope }));
36
+ return [];
37
+ };
38
+ return {
39
+ // biome-ignore lint/suspicious/noThenProperty: test stub models Drizzle's awaitable query builder -- owner: apps.
40
+ then: (resolve) => Promise.resolve(rows()).then(resolve),
41
+ orderBy: async () => rows(),
42
+ limit: async () => rows(),
43
+ };
44
+ },
45
+ }),
46
+ }),
47
+ });
48
+ }
49
+ const context = {
50
+ appId: "app_1",
51
+ installationId: "inst_1",
52
+ releaseId: "rel_1",
53
+ tokenMode: "offline",
54
+ scopes: [],
55
+ apiVersion: "2026-07-01",
56
+ };
57
+ describe("App API service boundary", () => {
58
+ it("rejects direct service calls when the installation is inactive", async () => {
59
+ const service = createAppApiService();
60
+ await expect(service.requireAccess(createAccessDb({ status: "paused", scopes: ["app-installation:read"] }), { ...context, scopes: ["app-installation:read"] }, ["app-installation:read"])).rejects.toMatchObject({ status: 403, code: "app_installation_not_active" });
61
+ });
62
+ it("rejects when the installation grant is missing even if the token carries the scope", async () => {
63
+ const service = createAppApiService();
64
+ await expect(service.requireAccess(createAccessDb({ scopes: [] }), { ...context, scopes: ["app-installation:read"] }, ["app-installation:read"])).rejects.toMatchObject({ status: 403, code: "app_installation_scope_missing" });
65
+ });
66
+ it("rejects when a narrowed online token lacks the scope the installation was granted", async () => {
67
+ const service = createAppApiService();
68
+ await expect(service.requireAccess(createAccessDb({ scopes: ["app-installation:read", "finance-documents:read"] }), { ...context, tokenMode: "online", scopes: ["app-installation:read"] }, ["finance-documents:read"])).rejects.toMatchObject({ status: 403, code: "app_api_token_scope_missing" });
69
+ });
70
+ it("fails closed when the requested API version is outside the installed release range", async () => {
71
+ const service = createAppApiService();
72
+ await expect(service.requireAccess(createAccessDb({
73
+ scopes: ["app-installation:read"],
74
+ releaseRange: { min: "2026-08-01", max: "2026-12-31" },
75
+ }), { ...context, scopes: ["app-installation:read"] }, ["app-installation:read"])).rejects.toMatchObject({ status: 426, code: "app_api_version_out_of_range" });
76
+ });
77
+ it("requires action-ledger approval for finance actions even when OAuth scope is granted", async () => {
78
+ const executeAction = vi.fn();
79
+ const service = createAppApiService({
80
+ finance: { listDocuments: vi.fn(), executeAction },
81
+ });
82
+ await expect(service.executeFinanceAction(createAccessDb({ scopes: ["finance-actions:issue"] }), { ...context, scopes: ["finance-actions:issue"] }, {
83
+ action: "issue",
84
+ idempotencyKey: "idem_1",
85
+ payload: {},
86
+ })).rejects.toMatchObject({ status: 403, code: "app_api_finance_approval_required" });
87
+ expect(executeAction).not.toHaveBeenCalled();
88
+ });
89
+ it("isolates rate limits per installation", async () => {
90
+ const service = createAppApiService({
91
+ rateLimit: { installationLimit: 1, appLimit: 10, windowMs: 60_000 },
92
+ });
93
+ const rateContext = { ...context, appId: "rate_app", installationId: "rate_inst_1" };
94
+ service.enforceRateLimit(rateContext);
95
+ expect(() => service.enforceRateLimit(rateContext)).toThrow(ApiHttpError);
96
+ expect(() => service.enforceRateLimit({ ...rateContext, installationId: "rate_inst_2" })).not.toThrow();
97
+ });
98
+ it("accepts only the $app custom-field namespace alias from request data", () => {
99
+ expect(() => assertAppNamespaceAlias(undefined)).not.toThrow();
100
+ expect(() => assertAppNamespaceAlias("$app")).not.toThrow();
101
+ expect(() => assertAppNamespaceAlias("$app:sync")).not.toThrow();
102
+ expect(() => assertAppNamespaceAlias("app--foreign")).toThrow(ApiHttpError);
103
+ expect(() => assertAppNamespaceAlias("custom")).toThrow(ApiHttpError);
104
+ });
105
+ it("compares App API compatibility ranges inclusively", () => {
106
+ expect(() => assertCompatibleVersion("2026-07-01", { min: "2026-07-01", max: "2026-12-31" })).not.toThrow();
107
+ expect(() => assertCompatibleVersion("2027-01-01", { min: "2026-07-01", max: "2026-12-31" })).toThrow(ApiHttpError);
108
+ });
109
+ });
package/dist/consent.js CHANGED
@@ -29,10 +29,25 @@ function canGrantScope(scope, remoteSafe, operatorGranted) {
29
29
  }
30
30
  function remoteSafeScopes(catalog) {
31
31
  const catalogScopes = new Set(catalog.resources.flatMap((resource) => resource.actions.map((action) => `${resource.resource}:${action.action}`)));
32
+ const remoteSafe = new Set();
33
+ // A scope is remote-safe when its owning resource is flagged remoteSafe, or
34
+ // when the specific action is. This is the authority for App API scopes;
35
+ // audience presets remain supported for operator-declared grant bundles.
36
+ for (const resource of catalog.resources) {
37
+ for (const action of resource.actions) {
38
+ if (resource.remoteSafe || action.remoteSafe) {
39
+ remoteSafe.add(`${resource.resource}:${action.action}`);
40
+ }
41
+ }
42
+ }
32
43
  const presetScopes = catalog.presets
33
44
  .filter((preset) => preset.kind === "api-token-grant" && Boolean(preset.audience))
34
45
  .flatMap((preset) => preset.grants);
35
- return new Set(presetScopes.filter((scope) => catalogScopes.has(scope)));
46
+ for (const scope of presetScopes) {
47
+ if (catalogScopes.has(scope))
48
+ remoteSafe.add(scope);
49
+ }
50
+ return remoteSafe;
36
51
  }
37
52
  function parseReleaseScopes(value) {
38
53
  if (!value || typeof value !== "object")
@@ -77,4 +77,28 @@ describe("app OAuth consent computation", () => {
77
77
  operatorGrantedScopes: ["invoices:read"],
78
78
  })).toThrow("Required app scopes are not grantable");
79
79
  });
80
+ it("grants App API scopes marked remoteSafe on the resource without a matching preset", () => {
81
+ const withRemoteSafe = {
82
+ ...catalog,
83
+ resources: [
84
+ ...catalog.resources,
85
+ {
86
+ id: "app-installation",
87
+ unitId: "apps",
88
+ resource: "app-installation",
89
+ label: "App installation",
90
+ description: "Self",
91
+ wildcard: "explicit-resource",
92
+ remoteSafe: true,
93
+ actions: [{ action: "read", label: "Read", description: "Read" }],
94
+ },
95
+ ],
96
+ };
97
+ const consent = computeAppConsent({
98
+ release: release(["app-installation:read"], []),
99
+ accessCatalog: withRemoteSafe,
100
+ operatorGrantedScopes: ["app-installation:read"],
101
+ });
102
+ expect(consent.grantedScopes).toEqual(["app-installation:read"]);
103
+ });
80
104
  });
@@ -29,8 +29,8 @@ export declare const appOwnedCustomFieldDeclarationSchema: z.ZodObject<{
29
29
  }, z.core.$strip>>>>;
30
30
  logicalNamespace: z.ZodOptional<z.ZodString>;
31
31
  dataClassification: z.ZodDefault<z.ZodEnum<{
32
- public: "public";
33
32
  internal: "internal";
33
+ public: "public";
34
34
  confidential: "confidential";
35
35
  restricted: "restricted";
36
36
  personal: "personal";
@@ -106,8 +106,8 @@ export declare const appManifestSchema: z.ZodPipe<z.ZodRecord<z.ZodString, z.Zod
106
106
  }, z.core.$strip>>>>;
107
107
  logicalNamespace: z.ZodOptional<z.ZodString>;
108
108
  dataClassification: z.ZodDefault<z.ZodEnum<{
109
- public: "public";
110
109
  internal: "internal";
110
+ public: "public";
111
111
  confidential: "confidential";
112
112
  restricted: "restricted";
113
113
  personal: "personal";
@@ -134,8 +134,8 @@ export declare const appManifestSchema: z.ZodPipe<z.ZodRecord<z.ZodString, z.Zod
134
134
  }, z.core.$strict>;
135
135
  data: z.ZodObject<{
136
136
  classifications: z.ZodArray<z.ZodEnum<{
137
- public: "public";
138
137
  internal: "internal";
138
+ public: "public";
139
139
  confidential: "confidential";
140
140
  restricted: "restricted";
141
141
  personal: "personal";
@@ -253,6 +253,32 @@ export declare const appCredentialRevocationSchema: z.ZodObject<{
253
253
  installationId: z.ZodString;
254
254
  actorId: z.ZodString;
255
255
  }, z.core.$strict>;
256
+ export declare const appSessionTokenIssueSchema: z.ZodObject<{
257
+ entity: z.ZodOptional<z.ZodObject<{
258
+ type: z.ZodString;
259
+ id: z.ZodString;
260
+ }, z.core.$strict>>;
261
+ slot: z.ZodOptional<z.ZodString>;
262
+ }, z.core.$strict>;
263
+ export declare const appSessionTokenExchangeSchema: z.ZodPipe<z.ZodObject<{
264
+ session_token: z.ZodString;
265
+ client_id: z.ZodString;
266
+ client_secret: z.ZodOptional<z.ZodString>;
267
+ viewer_scopes: z.ZodDefault<z.ZodArray<z.ZodString>>;
268
+ contextual_scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
269
+ }, z.core.$strict>, z.ZodTransform<{
270
+ client_secret: string | undefined;
271
+ session_token: string;
272
+ client_id: string;
273
+ viewer_scopes: string[];
274
+ contextual_scopes?: string[] | undefined;
275
+ }, {
276
+ session_token: string;
277
+ client_id: string;
278
+ viewer_scopes: string[];
279
+ client_secret?: string | undefined;
280
+ contextual_scopes?: string[] | undefined;
281
+ }>>;
256
282
  export type AppManifest = z.infer<typeof appManifestSchema>;
257
283
  export type CreateCustomAppRegistrationInput = z.infer<typeof createCustomAppRegistrationSchema>;
258
284
  export type ReleaseManifestUploadInput = z.infer<typeof releaseManifestUploadSchema>;
@@ -261,3 +287,5 @@ export type AppListQuery = z.infer<typeof appListQuerySchema>;
261
287
  export type AppWebhookReplayInput = z.infer<typeof appWebhookReplaySchema>;
262
288
  export type AppOAuthAuthorizeQuery = z.infer<typeof appOAuthAuthorizeQuerySchema>;
263
289
  export type AppOAuthTokenInput = z.infer<typeof appOAuthTokenSchema>;
290
+ export type AppSessionTokenIssueInput = z.infer<typeof appSessionTokenIssueSchema>;
291
+ export type AppSessionTokenExchangeInput = z.infer<typeof appSessionTokenExchangeSchema>;
package/dist/contracts.js CHANGED
@@ -271,3 +271,28 @@ export const appCredentialRevocationSchema = z
271
271
  actorId: z.string().trim().min(1),
272
272
  })
273
273
  .strict();
274
+ const sessionTokenEntitySchema = z
275
+ .object({
276
+ type: z.string().trim().min(1).max(80),
277
+ id: z.string().trim().min(1).max(200),
278
+ })
279
+ .strict();
280
+ export const appSessionTokenIssueSchema = z
281
+ .object({
282
+ entity: sessionTokenEntitySchema.optional(),
283
+ slot: z.string().trim().min(1).max(80).optional(),
284
+ })
285
+ .strict();
286
+ export const appSessionTokenExchangeSchema = z
287
+ .object({
288
+ session_token: z.string().trim().min(1),
289
+ client_id: z.string().trim().min(1),
290
+ client_secret: z.string().trim().optional(),
291
+ viewer_scopes: z.array(scopeSchema).default([]),
292
+ contextual_scopes: z.array(scopeSchema).optional(),
293
+ })
294
+ .strict()
295
+ .transform((input) => ({
296
+ ...input,
297
+ client_secret: input.client_secret?.trim() || undefined,
298
+ }));
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Installation-backed admin extension resolution (RFC §6, Phase 3).
3
+ *
4
+ * Replaces the static self-hosted descriptor list as the source of activated
5
+ * admin extensions: descriptors come from `app_extension_installations` for
6
+ * `active` installations only, so pausing, degrading, or uninstalling an app
7
+ * immediately drops its pages and slots. Slot extensions are filtered by
8
+ * release-pinned `extensionApi` compatibility, and host-rendered labels (nav
9
+ * entries, extension titles) plus the resolved app locale + text direction come
10
+ * from the installed release's declared locales and `app_release_localizations`.
11
+ *
12
+ * The returned descriptors are the exact shape the admin host consumes; static
13
+ * deployment-graph descriptors remain supported alongside these.
14
+ */
15
+ import { type UiExtensionDescriptor } from "@voyant-travel/admin-extension-sdk";
16
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
17
+ import { type AppTextDirection, type HostLabelRow } from "./locale-resolution.js";
18
+ /** A resolved slot/dashboard extension plus the app locale its frame renders in. */
19
+ export interface ResolvedUiSlotExtension {
20
+ descriptor: UiExtensionDescriptor;
21
+ installationId: string;
22
+ appId: string;
23
+ appLocale: string;
24
+ direction: AppTextDirection;
25
+ }
26
+ /** A resolved full-page app extension mounted under the app-owned admin route. */
27
+ export interface ResolvedAppPage {
28
+ /** Stable per-installation key (`<installationId>:<manifest key>`). */
29
+ key: string;
30
+ installationId: string;
31
+ appId: string;
32
+ /** App-owned admin sub-path declared by the manifest (e.g. `/settings`). */
33
+ path: string;
34
+ entryUrl: string;
35
+ /** Host-rendered page title, locale-resolved with deterministic fallback. */
36
+ title: string;
37
+ /** Host-rendered navigation label for the page's nav entry. */
38
+ navLabel: string;
39
+ appLocale: string;
40
+ direction: AppTextDirection;
41
+ }
42
+ export interface ResolvedInstalledExtensions {
43
+ slots: ResolvedUiSlotExtension[];
44
+ pages: ResolvedAppPage[];
45
+ }
46
+ export interface ResolveInstalledExtensionsInput {
47
+ deploymentId: string;
48
+ /** The staff member's active admin locale. */
49
+ activeLocale: string;
50
+ /** The extension API version the host implements (defaults to the SDK's). */
51
+ extensionApiVersion?: string;
52
+ }
53
+ /** One row of an active installation's active extension, as read from the store. */
54
+ export interface InstalledExtensionRow {
55
+ installationId: string;
56
+ appId: string;
57
+ extensionKey: string;
58
+ descriptor: Record<string, unknown>;
59
+ releaseId: string;
60
+ defaultLocale: string;
61
+ supportedLocales: readonly string[];
62
+ }
63
+ export declare function resolveInstalledExtensions(db: PostgresJsDatabase, input: ResolveInstalledExtensionsInput): Promise<ResolvedInstalledExtensions>;
64
+ /**
65
+ * Pure assembly of resolved descriptors from store rows and localization data.
66
+ * Applies compatibility filtering, locale resolution, and host-label lookup.
67
+ * The `rows` are assumed to already be scoped to active installations/extensions.
68
+ */
69
+ export declare function assembleInstalledExtensions(rows: readonly InstalledExtensionRow[], localizationsByRelease: Map<string, HostLabelRow[]>, options: {
70
+ activeLocale: string;
71
+ extensionApiVersion?: string;
72
+ }): ResolvedInstalledExtensions;