@voyant-travel/apps 0.3.0 → 0.4.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 +3 -3
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/installation-service.d.ts +1 -1
- package/dist/voyant.js +87 -1
- package/package.json +20 -5
|
@@ -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";
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -100,7 +100,7 @@ export declare function createAppInstallationService(options?: AppInstallationSe
|
|
|
100
100
|
uninstalledAt: Date | null;
|
|
101
101
|
purgedAt: Date | null;
|
|
102
102
|
};
|
|
103
|
-
outcome: "
|
|
103
|
+
outcome: "reinstalled" | "created";
|
|
104
104
|
}>;
|
|
105
105
|
upgrade: (db: PostgresJsDatabase, input: UpgradeAppInput) => Promise<{
|
|
106
106
|
installation: {
|
package/dist/voyant.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { defineModule } from "@voyant-travel/core/project";
|
|
1
|
+
import { defineModule, requirePort } from "@voyant-travel/core/project";
|
|
2
|
+
import { customFieldValueLifecycleRuntimePort, customFieldValueOperationsRuntimePort, } from "@voyant-travel/core/runtime-port";
|
|
2
3
|
const appInstallationLifecyclePayloadSchema = {
|
|
3
4
|
type: "object",
|
|
4
5
|
additionalProperties: false,
|
|
@@ -13,6 +14,16 @@ export const appsVoyantModule = defineModule({
|
|
|
13
14
|
id: "@voyant-travel/apps",
|
|
14
15
|
packageName: "@voyant-travel/apps",
|
|
15
16
|
localId: "apps",
|
|
17
|
+
runtimePorts: [
|
|
18
|
+
requirePort(customFieldValueLifecycleRuntimePort, {
|
|
19
|
+
optional: true,
|
|
20
|
+
cardinality: "many",
|
|
21
|
+
}),
|
|
22
|
+
requirePort(customFieldValueOperationsRuntimePort, {
|
|
23
|
+
optional: true,
|
|
24
|
+
cardinality: "many",
|
|
25
|
+
}),
|
|
26
|
+
],
|
|
16
27
|
api: [
|
|
17
28
|
{
|
|
18
29
|
id: "@voyant-travel/apps#api.admin",
|
|
@@ -82,6 +93,81 @@ export const appsVoyantModule = defineModule({
|
|
|
82
93
|
],
|
|
83
94
|
access: {
|
|
84
95
|
resources: [
|
|
96
|
+
{
|
|
97
|
+
id: "@voyant-travel/apps#access.app-installation",
|
|
98
|
+
resource: "app-installation",
|
|
99
|
+
label: "App installation",
|
|
100
|
+
description: "Read the calling app installation and grant state.",
|
|
101
|
+
remoteSafe: true,
|
|
102
|
+
actions: [{ action: "read", label: "Read installation", description: "Read self." }],
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: "@voyant-travel/apps#access.custom-field-definitions",
|
|
106
|
+
resource: "custom-field-definitions",
|
|
107
|
+
label: "Own custom-field definitions",
|
|
108
|
+
description: "Read and write custom-field definitions owned by the app.",
|
|
109
|
+
remoteSafe: true,
|
|
110
|
+
actions: ["read", "write"],
|
|
111
|
+
wildcard: "explicit-resource",
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: "@voyant-travel/apps#access.custom-field-values",
|
|
115
|
+
resource: "custom-field-values",
|
|
116
|
+
label: "Own custom-field values",
|
|
117
|
+
description: "Read and write app-owned custom-field values by target.",
|
|
118
|
+
remoteSafe: true,
|
|
119
|
+
actions: ["read", "write", "booking", "person", "organization", "invoice"],
|
|
120
|
+
wildcard: "explicit-resource",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: "@voyant-travel/apps#access.webhooks",
|
|
124
|
+
resource: "webhooks",
|
|
125
|
+
label: "App webhooks",
|
|
126
|
+
description: "Read webhook health and request replay.",
|
|
127
|
+
remoteSafe: true,
|
|
128
|
+
actions: [
|
|
129
|
+
"read",
|
|
130
|
+
{ action: "replay", label: "Replay webhooks", description: "Replay deliveries." },
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: "@voyant-travel/apps#access.app-audit",
|
|
135
|
+
resource: "app-audit",
|
|
136
|
+
label: "App audit history",
|
|
137
|
+
description: "Read audit history owned by the app.",
|
|
138
|
+
remoteSafe: true,
|
|
139
|
+
actions: ["read"],
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: "@voyant-travel/apps#access.online-token",
|
|
143
|
+
resource: "online-token",
|
|
144
|
+
label: "Online token exchange",
|
|
145
|
+
description: "Exchange admin session context for online app access.",
|
|
146
|
+
remoteSafe: true,
|
|
147
|
+
actions: [{ action: "exchange", wildcard: "explicit" }],
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
id: "@voyant-travel/apps#access.finance-documents",
|
|
151
|
+
resource: "finance-documents",
|
|
152
|
+
label: "Finance documents",
|
|
153
|
+
description: "Read finance documents visible to remote accounting apps.",
|
|
154
|
+
remoteSafe: true,
|
|
155
|
+
actions: ["read"],
|
|
156
|
+
wildcard: "explicit-resource",
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
id: "@voyant-travel/apps#access.finance-actions",
|
|
160
|
+
resource: "finance-actions",
|
|
161
|
+
label: "Finance actions",
|
|
162
|
+
description: "Request approved finance document actions.",
|
|
163
|
+
remoteSafe: true,
|
|
164
|
+
actions: [
|
|
165
|
+
{ action: "issue", sensitive: true, wildcard: "explicit" },
|
|
166
|
+
{ action: "retry", sensitive: true, wildcard: "explicit" },
|
|
167
|
+
{ action: "reconcile", sensitive: true, wildcard: "explicit" },
|
|
168
|
+
],
|
|
169
|
+
wildcard: "explicit-resource",
|
|
170
|
+
},
|
|
85
171
|
{
|
|
86
172
|
id: "@voyant-travel/apps#access.apps",
|
|
87
173
|
resource: "apps",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voyant-travel/apps",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -14,6 +14,21 @@
|
|
|
14
14
|
"import": "./dist/access-boundary.js",
|
|
15
15
|
"default": "./dist/access-boundary.js"
|
|
16
16
|
},
|
|
17
|
+
"./app-api-contracts": {
|
|
18
|
+
"types": "./dist/app-api-contracts.d.ts",
|
|
19
|
+
"import": "./dist/app-api-contracts.js",
|
|
20
|
+
"default": "./dist/app-api-contracts.js"
|
|
21
|
+
},
|
|
22
|
+
"./app-api-routes": {
|
|
23
|
+
"types": "./dist/app-api-routes.d.ts",
|
|
24
|
+
"import": "./dist/app-api-routes.js",
|
|
25
|
+
"default": "./dist/app-api-routes.js"
|
|
26
|
+
},
|
|
27
|
+
"./app-api-service": {
|
|
28
|
+
"types": "./dist/app-api-service.d.ts",
|
|
29
|
+
"import": "./dist/app-api-service.js",
|
|
30
|
+
"default": "./dist/app-api-service.js"
|
|
31
|
+
},
|
|
17
32
|
"./api-runtime": {
|
|
18
33
|
"types": "./dist/api-runtime.d.ts",
|
|
19
34
|
"import": "./dist/api-runtime.js",
|
|
@@ -81,13 +96,13 @@
|
|
|
81
96
|
"hono": "^4.12.27",
|
|
82
97
|
"zod": "^4.4.3",
|
|
83
98
|
"@voyant-travel/admin": "^0.126.2",
|
|
84
|
-
"@voyant-travel/core": "^0.125.1",
|
|
85
99
|
"@voyant-travel/admin-extension-sdk": "^0.1.1",
|
|
100
|
+
"@voyant-travel/core": "^0.125.2",
|
|
86
101
|
"@voyant-travel/custom-fields": "^0.2.1",
|
|
87
102
|
"@voyant-travel/db": "^0.114.10",
|
|
88
|
-
"@voyant-travel/
|
|
89
|
-
"@voyant-travel/
|
|
90
|
-
"@voyant-travel/
|
|
103
|
+
"@voyant-travel/hono": "^0.128.3",
|
|
104
|
+
"@voyant-travel/types": "^0.109.4",
|
|
105
|
+
"@voyant-travel/webhook-delivery": "^0.4.0"
|
|
91
106
|
},
|
|
92
107
|
"devDependencies": {
|
|
93
108
|
"@types/node": "^25.5.2",
|