@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.
- package/dist/api-runtime.d.ts +15 -0
- package/dist/api-runtime.js +15 -1
- package/dist/app-api-contracts.d.ts +51 -0
- package/dist/app-api-contracts.js +50 -0
- package/dist/app-api-routes.d.ts +28 -0
- package/dist/app-api-routes.js +141 -0
- package/dist/app-api-service.d.ts +1263 -0
- package/dist/app-api-service.js +244 -0
- package/dist/app-api-service.test.d.ts +1 -0
- package/dist/app-api-service.test.js +109 -0
- package/dist/consent.js +16 -1
- package/dist/consent.test.js +24 -0
- package/dist/contracts.d.ts +31 -3
- 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 +8 -1
- package/dist/index.js +8 -1
- package/dist/installation-service.d.ts +1 -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/dist/voyant.js +87 -1
- package/migrations/20260717150000_app_session_tokens.sql +18 -0
- package/migrations/meta/_journal.json +8 -1
- 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
|
-
|
|
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")
|
package/dist/consent.test.js
CHANGED
|
@@ -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
|
});
|
package/dist/contracts.d.ts
CHANGED
|
@@ -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;
|