@voyant-travel/apps 0.1.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/access-boundary.d.ts +29 -0
- package/dist/access-boundary.js +33 -0
- package/dist/api-runtime.d.ts +10 -0
- package/dist/api-runtime.js +8 -0
- package/dist/app-api-contracts.d.ts +51 -0
- package/dist/app-api-contracts.js +50 -0
- package/dist/app-api-routes.d.ts +26 -0
- package/dist/app-api-routes.js +133 -0
- package/dist/app-api-service.d.ts +1263 -0
- package/dist/app-api-service.js +231 -0
- package/dist/app-api-service.test.d.ts +1 -0
- package/dist/app-api-service.test.js +105 -0
- package/dist/compiler.d.ts +38 -0
- package/dist/compiler.js +118 -0
- package/dist/compiler.test.d.ts +1 -0
- package/dist/compiler.test.js +99 -0
- package/dist/consent.d.ts +15 -0
- package/dist/consent.js +50 -0
- package/dist/consent.test.d.ts +1 -0
- package/dist/consent.test.js +80 -0
- package/dist/contracts.d.ts +263 -0
- package/dist/contracts.js +273 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +12 -0
- package/dist/ingestion.d.ts +15 -0
- package/dist/ingestion.js +232 -0
- package/dist/ingestion.test.d.ts +1 -0
- package/dist/ingestion.test.js +47 -0
- package/dist/installation-reconciliation.d.ts +18 -0
- package/dist/installation-reconciliation.js +254 -0
- package/dist/installation-service.d.ts +351 -0
- package/dist/installation-service.js +328 -0
- package/dist/installation-state.d.ts +15 -0
- package/dist/installation-state.js +17 -0
- package/dist/installation-state.test.d.ts +1 -0
- package/dist/installation-state.test.js +38 -0
- package/dist/oauth-crypto.d.ts +8 -0
- package/dist/oauth-crypto.js +23 -0
- package/dist/oauth-crypto.test.d.ts +1 -0
- package/dist/oauth-crypto.test.js +11 -0
- package/dist/oauth-service.d.ts +65 -0
- package/dist/oauth-service.js +381 -0
- package/dist/oauth-service.test.d.ts +1 -0
- package/dist/oauth-service.test.js +8 -0
- package/dist/routes.d.ts +17 -0
- package/dist/routes.js +131 -0
- package/dist/schema.d.ts +2937 -0
- package/dist/schema.js +342 -0
- package/dist/service.d.ts +95 -0
- package/dist/service.js +172 -0
- package/dist/service.test.d.ts +1 -0
- package/dist/service.test.js +178 -0
- package/dist/test-fixtures.d.ts +80 -0
- package/dist/test-fixtures.js +78 -0
- package/dist/voyant.d.ts +2 -0
- package/dist/voyant.js +115 -0
- package/dist/webhook-delivery.d.ts +69 -0
- package/dist/webhook-delivery.js +262 -0
- package/migrations/20260717000100_app_registry_foundation.sql +87 -0
- package/migrations/20260717111938_breezy_karma.sql +136 -0
- package/migrations/20260717123000_app_webhook_delivery_health.sql +4 -0
- package/migrations/20260717143000_app_oauth_authorization.sql +47 -0
- package/migrations/meta/_journal.json +34 -0
- package/package.json +159 -0
|
@@ -0,0 +1,231 @@
|
|
|
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
|
+
const installation = await assertActiveAppInstallationAccess(db, {
|
|
137
|
+
installationId: context.installationId,
|
|
138
|
+
requiredScopes,
|
|
139
|
+
});
|
|
140
|
+
if (installation.appId !== context.appId || installation.releaseId !== context.releaseId) {
|
|
141
|
+
throw new ApiHttpError("App token does not match the active installation.", {
|
|
142
|
+
status: 403,
|
|
143
|
+
code: "app_api_token_installation_mismatch",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const [release] = await db
|
|
147
|
+
.select()
|
|
148
|
+
.from(appReleases)
|
|
149
|
+
.where(eq(appReleases.id, installation.releaseId))
|
|
150
|
+
.limit(1);
|
|
151
|
+
if (!release)
|
|
152
|
+
throw notFound("Installed app release not found.");
|
|
153
|
+
assertCompatibleVersion(context.apiVersion ?? APP_API_VERSION, release.apiCompatibility);
|
|
154
|
+
return { installation, release };
|
|
155
|
+
}
|
|
156
|
+
function enforceRateLimit(context) {
|
|
157
|
+
const policy = options.rateLimit ?? DEFAULT_RATE_LIMIT;
|
|
158
|
+
hit(`installation:${context.installationId}`, policy.installationLimit, policy.windowMs);
|
|
159
|
+
hit(`app:${context.appId}`, policy.appLimit, policy.windowMs);
|
|
160
|
+
}
|
|
161
|
+
function hit(key, limit, windowMs) {
|
|
162
|
+
const timestamp = now().getTime();
|
|
163
|
+
const bucket = buckets.get(key);
|
|
164
|
+
if (!bucket || bucket.resetAt <= timestamp) {
|
|
165
|
+
buckets.set(key, { count: 1, resetAt: timestamp + windowMs });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (bucket.count >= limit) {
|
|
169
|
+
throw new ApiHttpError("App API rate limit exceeded.", {
|
|
170
|
+
status: 429,
|
|
171
|
+
code: "app_api_rate_limited",
|
|
172
|
+
details: { key, resetAt: new Date(bucket.resetAt).toISOString() },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
bucket.count += 1;
|
|
176
|
+
}
|
|
177
|
+
function owner(access) {
|
|
178
|
+
return createAppCustomFieldDefinitionOwner({
|
|
179
|
+
appId: access.installation.appId,
|
|
180
|
+
namespace: access.installation.namespace,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
function customFieldsRequired() {
|
|
184
|
+
if (!customFields)
|
|
185
|
+
throw notSupported("Custom fields App API runtime is not configured.");
|
|
186
|
+
return customFields;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
export function assertAppNamespaceAlias(namespace) {
|
|
190
|
+
if (!namespace || namespace === "$app")
|
|
191
|
+
return;
|
|
192
|
+
if (PHYSICAL_APP_NAMESPACE.test(namespace) || !REMOTE_APP_NAMESPACE.test(namespace)) {
|
|
193
|
+
throw new ApiHttpError("App APIs accept only the server-resolved $app namespace alias.", {
|
|
194
|
+
status: 400,
|
|
195
|
+
code: "app_api_invalid_custom_field_namespace",
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
export function assertCompatibleVersion(requested, range) {
|
|
200
|
+
if (requested < range.min || requested > range.max) {
|
|
201
|
+
throw new ApiHttpError("Requested App API version is outside the installed release range.", {
|
|
202
|
+
status: 426,
|
|
203
|
+
code: "app_api_version_out_of_range",
|
|
204
|
+
details: { requested, supported: range },
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
export async function withAppApiDeadline(promise, timeoutMs = 5_000) {
|
|
209
|
+
let timeout;
|
|
210
|
+
try {
|
|
211
|
+
return await Promise.race([
|
|
212
|
+
promise,
|
|
213
|
+
new Promise((_, reject) => {
|
|
214
|
+
timeout = setTimeout(() => reject(new ApiHttpError("App API request deadline exceeded.", {
|
|
215
|
+
status: 504,
|
|
216
|
+
code: "app_api_deadline_exceeded",
|
|
217
|
+
})), timeoutMs);
|
|
218
|
+
}),
|
|
219
|
+
]);
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
if (timeout)
|
|
223
|
+
clearTimeout(timeout);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function notSupported(message) {
|
|
227
|
+
return new ApiHttpError(message, { status: 501, code: "app_api_not_configured" });
|
|
228
|
+
}
|
|
229
|
+
function notFound(message) {
|
|
230
|
+
return new ApiHttpError(message, { status: 404, code: "not_found" });
|
|
231
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,105 @@
|
|
|
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, ["app-installation:read"])).rejects.toMatchObject({ status: 403, code: "app_installation_not_active" });
|
|
61
|
+
});
|
|
62
|
+
it("rejects direct service calls when required scopes are not granted", async () => {
|
|
63
|
+
const service = createAppApiService();
|
|
64
|
+
await expect(service.requireAccess(createAccessDb({ scopes: [] }), context, ["app-installation:read"])).rejects.toMatchObject({ status: 403, code: "app_installation_scope_missing" });
|
|
65
|
+
});
|
|
66
|
+
it("fails closed when the requested API version is outside the installed release range", async () => {
|
|
67
|
+
const service = createAppApiService();
|
|
68
|
+
await expect(service.requireAccess(createAccessDb({
|
|
69
|
+
scopes: ["app-installation:read"],
|
|
70
|
+
releaseRange: { min: "2026-08-01", max: "2026-12-31" },
|
|
71
|
+
}), context, ["app-installation:read"])).rejects.toMatchObject({ status: 426, code: "app_api_version_out_of_range" });
|
|
72
|
+
});
|
|
73
|
+
it("requires action-ledger approval for finance actions even when OAuth scope is granted", async () => {
|
|
74
|
+
const executeAction = vi.fn();
|
|
75
|
+
const service = createAppApiService({
|
|
76
|
+
finance: { listDocuments: vi.fn(), executeAction },
|
|
77
|
+
});
|
|
78
|
+
await expect(service.executeFinanceAction(createAccessDb({ scopes: ["finance-actions:issue"] }), context, {
|
|
79
|
+
action: "issue",
|
|
80
|
+
idempotencyKey: "idem_1",
|
|
81
|
+
payload: {},
|
|
82
|
+
})).rejects.toMatchObject({ status: 403, code: "app_api_finance_approval_required" });
|
|
83
|
+
expect(executeAction).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
it("isolates rate limits per installation", async () => {
|
|
86
|
+
const service = createAppApiService({
|
|
87
|
+
rateLimit: { installationLimit: 1, appLimit: 10, windowMs: 60_000 },
|
|
88
|
+
});
|
|
89
|
+
const rateContext = { ...context, appId: "rate_app", installationId: "rate_inst_1" };
|
|
90
|
+
service.enforceRateLimit(rateContext);
|
|
91
|
+
expect(() => service.enforceRateLimit(rateContext)).toThrow(ApiHttpError);
|
|
92
|
+
expect(() => service.enforceRateLimit({ ...rateContext, installationId: "rate_inst_2" })).not.toThrow();
|
|
93
|
+
});
|
|
94
|
+
it("accepts only the $app custom-field namespace alias from request data", () => {
|
|
95
|
+
expect(() => assertAppNamespaceAlias(undefined)).not.toThrow();
|
|
96
|
+
expect(() => assertAppNamespaceAlias("$app")).not.toThrow();
|
|
97
|
+
expect(() => assertAppNamespaceAlias("$app:sync")).not.toThrow();
|
|
98
|
+
expect(() => assertAppNamespaceAlias("app--foreign")).toThrow(ApiHttpError);
|
|
99
|
+
expect(() => assertAppNamespaceAlias("custom")).toThrow(ApiHttpError);
|
|
100
|
+
});
|
|
101
|
+
it("compares App API compatibility ranges inclusively", () => {
|
|
102
|
+
expect(() => assertCompatibleVersion("2026-07-01", { min: "2026-07-01", max: "2026-12-31" })).not.toThrow();
|
|
103
|
+
expect(() => assertCompatibleVersion("2027-01-01", { min: "2026-07-01", max: "2026-12-31" })).toThrow(ApiHttpError);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { VoyantGraphEventCatalog } from "@voyant-travel/core/project";
|
|
2
|
+
import { type AppManifest } from "./contracts.js";
|
|
3
|
+
export interface CompiledAppManifest {
|
|
4
|
+
manifest: AppManifest;
|
|
5
|
+
canonicalJson: string;
|
|
6
|
+
digest: string;
|
|
7
|
+
normalizedRelease: NormalizedAppReleaseRecord;
|
|
8
|
+
}
|
|
9
|
+
export interface NormalizedAppReleaseRecord {
|
|
10
|
+
schemaVersion: "voyant.app-release.normalized.v1";
|
|
11
|
+
manifestSchemaVersion: AppManifest["schemaVersion"];
|
|
12
|
+
releaseVersion: string;
|
|
13
|
+
digest: string;
|
|
14
|
+
apiCompatibility: AppManifest["apiCompatibility"];
|
|
15
|
+
requestedScopes: readonly string[];
|
|
16
|
+
optionalScopes: readonly string[];
|
|
17
|
+
adminPages: AppManifest["admin"]["pages"];
|
|
18
|
+
slotExtensions: AppManifest["admin"]["slotExtensions"];
|
|
19
|
+
webhooks: AppManifest["webhooks"];
|
|
20
|
+
customFields: AppManifest["customFields"];
|
|
21
|
+
defaultLocale: string;
|
|
22
|
+
supportedLocales: readonly string[];
|
|
23
|
+
localizations: readonly NormalizedLocalization[];
|
|
24
|
+
urls: AppManifest["urls"];
|
|
25
|
+
data: AppManifest["data"];
|
|
26
|
+
}
|
|
27
|
+
export interface NormalizedLocalization {
|
|
28
|
+
locale: string;
|
|
29
|
+
surface: string;
|
|
30
|
+
messageKey: string;
|
|
31
|
+
text: string;
|
|
32
|
+
}
|
|
33
|
+
export interface CompileAppManifestOptions {
|
|
34
|
+
eventCatalog?: VoyantGraphEventCatalog;
|
|
35
|
+
}
|
|
36
|
+
export declare function compileAppManifest(input: unknown, options?: CompileAppManifestOptions): CompiledAppManifest;
|
|
37
|
+
export declare function canonicalJsonStringify(value: unknown): string;
|
|
38
|
+
export declare function canonicalize(value: unknown): unknown;
|
package/dist/compiler.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { isExternalWebhookPayloadSchema } from "@voyant-travel/core/project";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { appManifestSchema } from "./contracts.js";
|
|
5
|
+
export function compileAppManifest(input, options = {}) {
|
|
6
|
+
const manifest = appManifestSchema.parse(input);
|
|
7
|
+
validateWebhookSubscriptions(manifest, options.eventCatalog);
|
|
8
|
+
const normalized = normalizeManifest(manifest);
|
|
9
|
+
const canonicalJson = canonicalJsonStringify(normalized);
|
|
10
|
+
const digest = `sha256:${createHash("sha256").update(canonicalJson).digest("hex")}`;
|
|
11
|
+
return {
|
|
12
|
+
manifest,
|
|
13
|
+
canonicalJson,
|
|
14
|
+
digest,
|
|
15
|
+
normalizedRelease: {
|
|
16
|
+
schemaVersion: "voyant.app-release.normalized.v1",
|
|
17
|
+
manifestSchemaVersion: manifest.schemaVersion,
|
|
18
|
+
releaseVersion: manifest.releaseVersion,
|
|
19
|
+
digest,
|
|
20
|
+
apiCompatibility: manifest.apiCompatibility,
|
|
21
|
+
requestedScopes: normalized.scopes.requested,
|
|
22
|
+
optionalScopes: normalized.scopes.optional,
|
|
23
|
+
adminPages: normalized.admin.pages,
|
|
24
|
+
slotExtensions: normalized.admin.slotExtensions,
|
|
25
|
+
webhooks: normalized.webhooks,
|
|
26
|
+
customFields: normalized.customFields,
|
|
27
|
+
defaultLocale: normalized.locales.default,
|
|
28
|
+
supportedLocales: normalized.locales.supported,
|
|
29
|
+
localizations: flattenLocalizations(normalized),
|
|
30
|
+
urls: normalized.urls,
|
|
31
|
+
data: normalized.data,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function canonicalJsonStringify(value) {
|
|
36
|
+
return JSON.stringify(canonicalize(value));
|
|
37
|
+
}
|
|
38
|
+
export function canonicalize(value) {
|
|
39
|
+
if (Array.isArray(value))
|
|
40
|
+
return value.map((item) => canonicalize(item));
|
|
41
|
+
if (!value || typeof value !== "object")
|
|
42
|
+
return value;
|
|
43
|
+
const record = value;
|
|
44
|
+
return Object.fromEntries(Object.keys(record)
|
|
45
|
+
.sort()
|
|
46
|
+
.map((key) => [key, canonicalize(record[key])]));
|
|
47
|
+
}
|
|
48
|
+
function normalizeManifest(manifest) {
|
|
49
|
+
return {
|
|
50
|
+
...manifest,
|
|
51
|
+
scopes: {
|
|
52
|
+
requested: sortedUnique(manifest.scopes.requested),
|
|
53
|
+
optional: sortedUnique(manifest.scopes.optional),
|
|
54
|
+
},
|
|
55
|
+
admin: {
|
|
56
|
+
pages: [...manifest.admin.pages].sort(byKey),
|
|
57
|
+
slotExtensions: [...manifest.admin.slotExtensions]
|
|
58
|
+
.map((extension) => ({ ...extension, slots: sortedUnique(extension.slots) }))
|
|
59
|
+
.sort(byKey),
|
|
60
|
+
},
|
|
61
|
+
webhooks: [...manifest.webhooks].sort((left, right) => `${left.eventType}:${left.eventVersion}`.localeCompare(`${right.eventType}:${right.eventVersion}`)),
|
|
62
|
+
customFields: [...manifest.customFields].sort((left, right) => `${left.entityType}:${left.logicalNamespace ?? ""}:${left.key}`.localeCompare(`${right.entityType}:${right.logicalNamespace ?? ""}:${right.key}`)),
|
|
63
|
+
locales: {
|
|
64
|
+
default: manifest.locales.default,
|
|
65
|
+
supported: sortedUnique(manifest.locales.supported),
|
|
66
|
+
host: Object.fromEntries(Object.entries(manifest.locales.host).sort(([left], [right]) => left.localeCompare(right))),
|
|
67
|
+
},
|
|
68
|
+
data: {
|
|
69
|
+
...manifest.data,
|
|
70
|
+
classifications: sortedUnique(manifest.data.classifications),
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function validateWebhookSubscriptions(manifest, eventCatalog) {
|
|
75
|
+
if (!eventCatalog)
|
|
76
|
+
return;
|
|
77
|
+
const externalEvents = new Map();
|
|
78
|
+
for (const event of eventCatalog.events) {
|
|
79
|
+
if (event.visibility === "external" && isExternalWebhookPayloadSchema(event.payloadSchema)) {
|
|
80
|
+
externalEvents.set(`${event.eventType}:${event.version}`, event);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const subscription of manifest.webhooks) {
|
|
84
|
+
if (!externalEvents.has(`${subscription.eventType}:${subscription.eventVersion}`)) {
|
|
85
|
+
throw new z.ZodError([
|
|
86
|
+
{
|
|
87
|
+
code: "custom",
|
|
88
|
+
path: ["webhooks"],
|
|
89
|
+
message: `Webhook subscription "${subscription.eventType}" version "${subscription.eventVersion}" is not an external event contract in the selected graph.`,
|
|
90
|
+
input: subscription,
|
|
91
|
+
},
|
|
92
|
+
]);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function flattenLocalizations(manifest) {
|
|
97
|
+
const rows = [];
|
|
98
|
+
for (const [locale, metadata] of Object.entries(manifest.locales.host)) {
|
|
99
|
+
rows.push({ locale, surface: "app", messageKey: "name", text: metadata.name });
|
|
100
|
+
rows.push({ locale, surface: "app", messageKey: "summary", text: metadata.summary });
|
|
101
|
+
for (const [key, text] of Object.entries(metadata.navigation)) {
|
|
102
|
+
rows.push({ locale, surface: "navigation", messageKey: key, text });
|
|
103
|
+
}
|
|
104
|
+
for (const [key, text] of Object.entries(metadata.extensions)) {
|
|
105
|
+
rows.push({ locale, surface: "extension", messageKey: key, text });
|
|
106
|
+
}
|
|
107
|
+
for (const [key, text] of Object.entries(metadata.setup)) {
|
|
108
|
+
rows.push({ locale, surface: "setup", messageKey: key, text });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return rows.sort((left, right) => `${left.locale}:${left.surface}:${left.messageKey}`.localeCompare(`${right.locale}:${right.surface}:${right.messageKey}`));
|
|
112
|
+
}
|
|
113
|
+
function sortedUnique(values) {
|
|
114
|
+
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
|
|
115
|
+
}
|
|
116
|
+
function byKey(left, right) {
|
|
117
|
+
return left.key.localeCompare(right.key);
|
|
118
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { compileAppManifest } from "./compiler.js";
|
|
3
|
+
import { appManifestSchema } from "./contracts.js";
|
|
4
|
+
import { validManifest } from "./test-fixtures.js";
|
|
5
|
+
describe("app manifest compiler", () => {
|
|
6
|
+
it("accepts a closed v1 manifest and produces a stable digest", () => {
|
|
7
|
+
const first = compileAppManifest(validManifest);
|
|
8
|
+
const second = compileAppManifest({
|
|
9
|
+
...validManifest,
|
|
10
|
+
scopes: { optional: ["invoices:read"], requested: ["bookings:read"] },
|
|
11
|
+
});
|
|
12
|
+
expect(first.digest).toMatch(/^sha256:/);
|
|
13
|
+
expect(second.digest).toBe(first.digest);
|
|
14
|
+
expect(first.normalizedRelease.defaultLocale).toBe("en-US");
|
|
15
|
+
expect(first.normalizedRelease.localizations).toEqual(expect.arrayContaining([
|
|
16
|
+
expect.objectContaining({ locale: "en-US", surface: "app", messageKey: "name" }),
|
|
17
|
+
expect.objectContaining({
|
|
18
|
+
locale: "en-US",
|
|
19
|
+
surface: "extension",
|
|
20
|
+
messageKey: "booking-panel",
|
|
21
|
+
}),
|
|
22
|
+
]));
|
|
23
|
+
});
|
|
24
|
+
it("rejects forbidden declaration classes with actionable paths", () => {
|
|
25
|
+
const result = appManifestSchema.safeParse({
|
|
26
|
+
...validManifest,
|
|
27
|
+
migrations: ["001.sql"],
|
|
28
|
+
providers: [{ role: "database" }],
|
|
29
|
+
scripts: { postinstall: "node install.js" },
|
|
30
|
+
dependencies: { leftpad: "1.0.0" },
|
|
31
|
+
exports: { ".": "./index.js" },
|
|
32
|
+
files: ["dist/index.js"],
|
|
33
|
+
});
|
|
34
|
+
expect(result.success).toBe(false);
|
|
35
|
+
if (!result.success) {
|
|
36
|
+
expect(result.error.issues.map((issue) => issue.path.join("."))).toEqual(expect.arrayContaining(["migrations", "providers", "scripts", "dependencies", "exports"]));
|
|
37
|
+
expect(result.error.issues.map((issue) => issue.message).join("\n")).toContain("forbidden");
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
it("validates slot ids and external webhook event versions", () => {
|
|
41
|
+
expect(() => compileAppManifest({
|
|
42
|
+
...validManifest,
|
|
43
|
+
admin: {
|
|
44
|
+
...validManifest.admin,
|
|
45
|
+
slotExtensions: [{ ...validManifest.admin.slotExtensions[0], slots: ["unknown.slot"] }],
|
|
46
|
+
},
|
|
47
|
+
})).toThrow();
|
|
48
|
+
expect(() => compileAppManifest(validManifest, {
|
|
49
|
+
eventCatalog: {
|
|
50
|
+
schemaVersion: "voyant.event-catalog.v1",
|
|
51
|
+
events: [
|
|
52
|
+
{
|
|
53
|
+
key: "booking.created@2.0.0",
|
|
54
|
+
id: "event.booking.created",
|
|
55
|
+
unitId: "@voyant-travel/bookings",
|
|
56
|
+
packageName: "@voyant-travel/bookings",
|
|
57
|
+
eventType: "booking.created",
|
|
58
|
+
version: "2.0.0",
|
|
59
|
+
payloadSchema: { type: "object", properties: {} },
|
|
60
|
+
visibility: "external",
|
|
61
|
+
audit: { sourceModule: "@voyant-travel/bookings", category: "domain" },
|
|
62
|
+
redactedFields: [],
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
})).toThrow(/not an external event contract/);
|
|
67
|
+
expect(() => compileAppManifest(validManifest, {
|
|
68
|
+
eventCatalog: {
|
|
69
|
+
schemaVersion: "voyant.event-catalog.v1",
|
|
70
|
+
events: [
|
|
71
|
+
{
|
|
72
|
+
key: "booking.created@1.0.0",
|
|
73
|
+
id: "event.booking.created",
|
|
74
|
+
unitId: "@voyant-travel/bookings",
|
|
75
|
+
packageName: "@voyant-travel/bookings",
|
|
76
|
+
eventType: "booking.created",
|
|
77
|
+
version: "1.0.0",
|
|
78
|
+
payloadSchema: { type: "object", properties: {} },
|
|
79
|
+
visibility: "internal",
|
|
80
|
+
audit: { sourceModule: "@voyant-travel/bookings", category: "domain" },
|
|
81
|
+
redactedFields: [],
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
})).toThrow(/not an external event contract/);
|
|
86
|
+
});
|
|
87
|
+
it("rejects webhook endpoints that target local infrastructure", () => {
|
|
88
|
+
expect(() => compileAppManifest({
|
|
89
|
+
...validManifest,
|
|
90
|
+
webhooks: [
|
|
91
|
+
{
|
|
92
|
+
eventType: "booking.created",
|
|
93
|
+
eventVersion: "1.0.0",
|
|
94
|
+
endpointUrl: "https://127.0.0.1/webhooks/voyant",
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
})).toThrow(/local or private hosts/);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AccessCatalog } from "@voyant-travel/types/api-keys";
|
|
2
|
+
import type { AppRelease } from "./schema.js";
|
|
3
|
+
export interface ConsentComputationInput {
|
|
4
|
+
release: AppRelease;
|
|
5
|
+
accessCatalog: AccessCatalog;
|
|
6
|
+
operatorGrantedScopes: readonly string[];
|
|
7
|
+
grantedOptionalScopes?: readonly string[];
|
|
8
|
+
}
|
|
9
|
+
export interface ComputedConsent {
|
|
10
|
+
requiredScopes: string[];
|
|
11
|
+
optionalScopes: string[];
|
|
12
|
+
grantedScopes: string[];
|
|
13
|
+
deniedOptionalScopes: string[];
|
|
14
|
+
}
|
|
15
|
+
export declare function computeAppConsent(input: ConsentComputationInput): ComputedConsent;
|
package/dist/consent.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { ApiHttpError } from "@voyant-travel/hono";
|
|
2
|
+
export function computeAppConsent(input) {
|
|
3
|
+
const normalized = parseReleaseScopes(input.release.normalizedRecord);
|
|
4
|
+
const remoteSafe = remoteSafeScopes(input.accessCatalog);
|
|
5
|
+
const operatorGranted = new Set(input.operatorGrantedScopes);
|
|
6
|
+
const optionalGrantRequest = new Set(input.grantedOptionalScopes ?? []);
|
|
7
|
+
const requiredScopes = normalized.requestedScopes.filter((scope) => canGrantScope(scope, remoteSafe, operatorGranted));
|
|
8
|
+
const missingRequired = normalized.requestedScopes.filter((scope) => !requiredScopes.includes(scope));
|
|
9
|
+
if (missingRequired.length > 0) {
|
|
10
|
+
throw new ApiHttpError("Required app scopes are not grantable for remote apps", {
|
|
11
|
+
status: 403,
|
|
12
|
+
code: "app_required_scope_not_grantable",
|
|
13
|
+
details: { scopes: missingRequired },
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
const optionalScopes = normalized.optionalScopes.filter((scope) => canGrantScope(scope, remoteSafe, operatorGranted));
|
|
17
|
+
const grantedOptional = optionalScopes.filter((scope) => optionalGrantRequest.has(scope));
|
|
18
|
+
const deniedOptionalScopes = normalized.optionalScopes.filter((scope) => !grantedOptional.includes(scope));
|
|
19
|
+
const grantedScopes = Array.from(new Set([...requiredScopes, ...grantedOptional])).sort();
|
|
20
|
+
return {
|
|
21
|
+
requiredScopes: requiredScopes.sort(),
|
|
22
|
+
optionalScopes: optionalScopes.sort(),
|
|
23
|
+
grantedScopes,
|
|
24
|
+
deniedOptionalScopes: deniedOptionalScopes.sort(),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function canGrantScope(scope, remoteSafe, operatorGranted) {
|
|
28
|
+
return remoteSafe.has(scope) && operatorGranted.has(scope);
|
|
29
|
+
}
|
|
30
|
+
function remoteSafeScopes(catalog) {
|
|
31
|
+
const catalogScopes = new Set(catalog.resources.flatMap((resource) => resource.actions.map((action) => `${resource.resource}:${action.action}`)));
|
|
32
|
+
const presetScopes = catalog.presets
|
|
33
|
+
.filter((preset) => preset.kind === "api-token-grant" && Boolean(preset.audience))
|
|
34
|
+
.flatMap((preset) => preset.grants);
|
|
35
|
+
return new Set(presetScopes.filter((scope) => catalogScopes.has(scope)));
|
|
36
|
+
}
|
|
37
|
+
function parseReleaseScopes(value) {
|
|
38
|
+
if (!value || typeof value !== "object")
|
|
39
|
+
return { requestedScopes: [], optionalScopes: [] };
|
|
40
|
+
const record = value;
|
|
41
|
+
return {
|
|
42
|
+
requestedScopes: stringArray(record.requestedScopes),
|
|
43
|
+
optionalScopes: stringArray(record.optionalScopes),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function stringArray(value) {
|
|
47
|
+
return Array.isArray(value)
|
|
48
|
+
? Array.from(new Set(value.filter((item) => typeof item === "string"))).sort()
|
|
49
|
+
: [];
|
|
50
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|