@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.
Files changed (64) hide show
  1. package/dist/access-boundary.d.ts +29 -0
  2. package/dist/access-boundary.js +33 -0
  3. package/dist/api-runtime.d.ts +10 -0
  4. package/dist/api-runtime.js +8 -0
  5. package/dist/app-api-contracts.d.ts +51 -0
  6. package/dist/app-api-contracts.js +50 -0
  7. package/dist/app-api-routes.d.ts +26 -0
  8. package/dist/app-api-routes.js +133 -0
  9. package/dist/app-api-service.d.ts +1263 -0
  10. package/dist/app-api-service.js +231 -0
  11. package/dist/app-api-service.test.d.ts +1 -0
  12. package/dist/app-api-service.test.js +105 -0
  13. package/dist/compiler.d.ts +38 -0
  14. package/dist/compiler.js +118 -0
  15. package/dist/compiler.test.d.ts +1 -0
  16. package/dist/compiler.test.js +99 -0
  17. package/dist/consent.d.ts +15 -0
  18. package/dist/consent.js +50 -0
  19. package/dist/consent.test.d.ts +1 -0
  20. package/dist/consent.test.js +80 -0
  21. package/dist/contracts.d.ts +263 -0
  22. package/dist/contracts.js +273 -0
  23. package/dist/index.d.ts +13 -0
  24. package/dist/index.js +12 -0
  25. package/dist/ingestion.d.ts +15 -0
  26. package/dist/ingestion.js +232 -0
  27. package/dist/ingestion.test.d.ts +1 -0
  28. package/dist/ingestion.test.js +47 -0
  29. package/dist/installation-reconciliation.d.ts +18 -0
  30. package/dist/installation-reconciliation.js +254 -0
  31. package/dist/installation-service.d.ts +351 -0
  32. package/dist/installation-service.js +328 -0
  33. package/dist/installation-state.d.ts +15 -0
  34. package/dist/installation-state.js +17 -0
  35. package/dist/installation-state.test.d.ts +1 -0
  36. package/dist/installation-state.test.js +38 -0
  37. package/dist/oauth-crypto.d.ts +8 -0
  38. package/dist/oauth-crypto.js +23 -0
  39. package/dist/oauth-crypto.test.d.ts +1 -0
  40. package/dist/oauth-crypto.test.js +11 -0
  41. package/dist/oauth-service.d.ts +65 -0
  42. package/dist/oauth-service.js +381 -0
  43. package/dist/oauth-service.test.d.ts +1 -0
  44. package/dist/oauth-service.test.js +8 -0
  45. package/dist/routes.d.ts +17 -0
  46. package/dist/routes.js +131 -0
  47. package/dist/schema.d.ts +2937 -0
  48. package/dist/schema.js +342 -0
  49. package/dist/service.d.ts +95 -0
  50. package/dist/service.js +172 -0
  51. package/dist/service.test.d.ts +1 -0
  52. package/dist/service.test.js +178 -0
  53. package/dist/test-fixtures.d.ts +80 -0
  54. package/dist/test-fixtures.js +78 -0
  55. package/dist/voyant.d.ts +2 -0
  56. package/dist/voyant.js +115 -0
  57. package/dist/webhook-delivery.d.ts +69 -0
  58. package/dist/webhook-delivery.js +262 -0
  59. package/migrations/20260717000100_app_registry_foundation.sql +87 -0
  60. package/migrations/20260717111938_breezy_karma.sql +136 -0
  61. package/migrations/20260717123000_app_webhook_delivery_health.sql +4 -0
  62. package/migrations/20260717143000_app_oauth_authorization.sql +47 -0
  63. package/migrations/meta/_journal.json +34 -0
  64. package/package.json +159 -0
@@ -0,0 +1,232 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { request as httpsRequest } from "node:https";
3
+ import { isIP } from "node:net";
4
+ const DEFAULT_MAX_BYTES = 256 * 1024;
5
+ const DEFAULT_MAX_REDIRECTS = 3;
6
+ const DEFAULT_TIMEOUT_MS = 5000;
7
+ /** Hard transport-level body cap; the caller enforces the tighter manifest cap. */
8
+ const TRANSPORT_MAX_BODY_BYTES = 1024 * 1024;
9
+ const DEFAULT_CONTENT_TYPES = ["application/json", "application/manifest+json"];
10
+ export async function fetchProtectedManifest(inputUrl, options = {}) {
11
+ const fetcher = options.fetch ?? createPinnedFetch(options.resolveHost);
12
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
13
+ const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
14
+ const allowedContentTypes = options.allowedContentTypes ?? DEFAULT_CONTENT_TYPES;
15
+ let current = requireHttpsUrl(inputUrl);
16
+ for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount += 1) {
17
+ await assertPublicHostname(current, options.resolveHost);
18
+ const controller = new AbortController();
19
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
20
+ try {
21
+ const response = await fetcher(current, {
22
+ method: "GET",
23
+ redirect: "manual",
24
+ signal: controller.signal,
25
+ headers: { accept: "application/json, application/manifest+json" },
26
+ });
27
+ if (isRedirect(response.status)) {
28
+ if (redirectCount === maxRedirects) {
29
+ throw new Error("Manifest fetch exceeded the maximum redirect count.");
30
+ }
31
+ const location = response.headers.get("location");
32
+ if (!location)
33
+ throw new Error("Manifest redirect did not include a Location header.");
34
+ current = requireHttpsUrl(new URL(location, current).toString());
35
+ continue;
36
+ }
37
+ if (!response.ok) {
38
+ throw new Error(`Manifest fetch failed with HTTP ${response.status}.`);
39
+ }
40
+ const contentType = response.headers.get("content-type")?.split(";")[0]?.trim() ?? "";
41
+ if (!allowedContentTypes.includes(contentType)) {
42
+ throw new Error(`Manifest response content-type "${contentType}" is not allowed.`);
43
+ }
44
+ const text = await readBoundedResponse(response, maxBytes);
45
+ return {
46
+ url: current,
47
+ contentType,
48
+ bytes: new TextEncoder().encode(text).byteLength,
49
+ body: JSON.parse(text),
50
+ };
51
+ }
52
+ catch (error) {
53
+ if (error instanceof SyntaxError)
54
+ throw new Error("Manifest response was not valid JSON.");
55
+ if (error.name === "AbortError") {
56
+ throw new Error("Manifest fetch timed out.");
57
+ }
58
+ throw error;
59
+ }
60
+ finally {
61
+ clearTimeout(timeout);
62
+ }
63
+ }
64
+ throw new Error("Manifest fetch failed.");
65
+ }
66
+ async function readBoundedResponse(response, maxBytes) {
67
+ const contentLength = response.headers.get("content-length");
68
+ if (contentLength) {
69
+ const size = Number(contentLength);
70
+ if (Number.isFinite(size) && size > maxBytes) {
71
+ throw new Error("Manifest response exceeded the maximum allowed size.");
72
+ }
73
+ }
74
+ const reader = response.body?.getReader();
75
+ if (!reader)
76
+ return response.text();
77
+ const chunks = [];
78
+ let total = 0;
79
+ while (true) {
80
+ const { done, value } = await reader.read();
81
+ if (done)
82
+ break;
83
+ if (value) {
84
+ total += value.byteLength;
85
+ if (total > maxBytes)
86
+ throw new Error("Manifest response exceeded the maximum allowed size.");
87
+ chunks.push(value);
88
+ }
89
+ }
90
+ const body = new Uint8Array(total);
91
+ let offset = 0;
92
+ for (const chunk of chunks) {
93
+ body.set(chunk, offset);
94
+ offset += chunk.byteLength;
95
+ }
96
+ return new TextDecoder().decode(body);
97
+ }
98
+ function requireHttpsUrl(value) {
99
+ const url = new URL(value);
100
+ if (url.protocol !== "https:")
101
+ throw new Error("Manifest URL must use HTTPS.");
102
+ if (url.username || url.password)
103
+ throw new Error("Manifest URL must not include credentials.");
104
+ return url.toString();
105
+ }
106
+ async function assertPublicHostname(value, resolveHost = defaultResolveHost) {
107
+ const url = new URL(value);
108
+ if (isPrivateHostname(url.hostname)) {
109
+ throw new Error("Manifest URL host is not publicly routable.");
110
+ }
111
+ const addresses = await resolveHost(url.hostname);
112
+ if (addresses.length === 0)
113
+ throw new Error("Manifest URL host did not resolve.");
114
+ for (const address of addresses) {
115
+ if (isPrivateAddress(address)) {
116
+ throw new Error("Manifest URL resolved to a private or reserved address.");
117
+ }
118
+ }
119
+ }
120
+ async function defaultResolveHost(hostname) {
121
+ const result = await lookup(hostname, { all: true, verbatim: true });
122
+ return result.map((entry) => entry.address);
123
+ }
124
+ /**
125
+ * A fetch-compatible transport whose socket connects only to addresses that
126
+ * were validated inside the same DNS resolution. `assertPublicHostname` alone
127
+ * is a time-of-check/time-of-use guard: a publisher-controlled name can
128
+ * rebind between the check and the connect. Pinning validation into the
129
+ * connection `lookup` closes that window.
130
+ */
131
+ function createPinnedFetch(resolveHost) {
132
+ const pinnedLookup = (hostname, lookupOptions, callback) => {
133
+ const resolve = async () => {
134
+ const addresses = resolveHost
135
+ ? (await resolveHost(hostname)).map((address) => ({
136
+ address,
137
+ family: isIP(address) === 6 ? 6 : 4,
138
+ }))
139
+ : await lookup(hostname, { all: true, verbatim: true });
140
+ if (addresses.length === 0) {
141
+ throw new Error("Manifest URL host did not resolve.");
142
+ }
143
+ for (const entry of addresses) {
144
+ if (isPrivateAddress(entry.address)) {
145
+ throw new Error("Manifest URL resolved to a private or reserved address.");
146
+ }
147
+ }
148
+ return addresses;
149
+ };
150
+ resolve().then((addresses) => {
151
+ if (lookupOptions.all) {
152
+ callback(null, addresses);
153
+ return;
154
+ }
155
+ const first = addresses[0];
156
+ callback(null, first.address, first.family);
157
+ }, (error) => callback(error, "", 4));
158
+ };
159
+ return (input, init) => new Promise((resolvePromise, rejectPromise) => {
160
+ const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
161
+ const headers = {};
162
+ if (init?.headers && !Array.isArray(init.headers) && !(init.headers instanceof Headers)) {
163
+ Object.assign(headers, init.headers);
164
+ }
165
+ const request = httpsRequest({
166
+ host: url.hostname,
167
+ port: url.port === "" ? 443 : Number(url.port),
168
+ path: `${url.pathname}${url.search}`,
169
+ method: init?.method ?? "GET",
170
+ headers,
171
+ signal: init?.signal ?? undefined,
172
+ lookup: pinnedLookup,
173
+ }, (incoming) => {
174
+ const status = incoming.statusCode ?? 0;
175
+ const responseHeaders = new Headers();
176
+ for (const [name, value] of Object.entries(incoming.headers)) {
177
+ if (typeof value === "string")
178
+ responseHeaders.set(name, value);
179
+ else if (Array.isArray(value))
180
+ responseHeaders.set(name, value.join(", "));
181
+ }
182
+ const chunks = [];
183
+ let received = 0;
184
+ incoming.on("data", (chunk) => {
185
+ received += chunk.byteLength;
186
+ if (received > TRANSPORT_MAX_BODY_BYTES) {
187
+ incoming.destroy();
188
+ rejectPromise(new Error("Manifest response exceeded the maximum allowed size."));
189
+ return;
190
+ }
191
+ chunks.push(chunk);
192
+ });
193
+ incoming.on("end", () => {
194
+ const body = status === 204 || status === 304 ? null : new Uint8Array(Buffer.concat(chunks));
195
+ resolvePromise(new Response(body, { status, headers: responseHeaders }));
196
+ });
197
+ incoming.on("error", rejectPromise);
198
+ });
199
+ request.on("error", rejectPromise);
200
+ request.end();
201
+ });
202
+ }
203
+ function isRedirect(status) {
204
+ return status >= 300 && status < 400;
205
+ }
206
+ function isPrivateHostname(hostname) {
207
+ return (hostname === "localhost" ||
208
+ hostname.endsWith(".localhost") ||
209
+ hostname.endsWith(".local") ||
210
+ hostname.endsWith(".internal"));
211
+ }
212
+ function isPrivateAddress(address) {
213
+ if (isIP(address) === 0)
214
+ return true;
215
+ if (address === "::1" || address === "0:0:0:0:0:0:0:1")
216
+ return true;
217
+ if (address.startsWith("fc") || address.startsWith("fd") || address.startsWith("fe80:")) {
218
+ return true;
219
+ }
220
+ const parts = address.split(".").map((part) => Number(part));
221
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part)))
222
+ return false;
223
+ const first = parts[0];
224
+ const second = parts[1];
225
+ return (first === 10 ||
226
+ first === 127 ||
227
+ first === 0 ||
228
+ (first === 169 && second === 254) ||
229
+ (first === 172 && second >= 16 && second <= 31) ||
230
+ (first === 192 && second === 168) ||
231
+ first >= 224);
232
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { fetchProtectedManifest } from "./ingestion.js";
3
+ import { validManifest } from "./test-fixtures.js";
4
+ const manifestResponse = (contentType = "application/json") => new Response(JSON.stringify(validManifest), {
5
+ headers: { "content-type": contentType },
6
+ });
7
+ describe("protected manifest ingestion", () => {
8
+ it("fetches HTTPS JSON through public DNS and parses bounded content", async () => {
9
+ const fetcher = async () => manifestResponse();
10
+ await expect(fetchProtectedManifest("https://app.example.com/manifest.json", {
11
+ fetch: fetcher,
12
+ resolveHost: async () => ["203.0.113.10"],
13
+ })).resolves.toMatchObject({ contentType: "application/json", body: validManifest });
14
+ });
15
+ it("rejects private DNS answers, unsafe redirects, oversize bodies, and content types", async () => {
16
+ await expect(fetchProtectedManifest("https://app.example.com/manifest.json", {
17
+ fetch: async () => {
18
+ throw new Error("private DNS should fail before fetch");
19
+ },
20
+ resolveHost: async () => ["127.0.0.1"],
21
+ })).rejects.toThrow(/private or reserved/);
22
+ await expect(fetchProtectedManifest("https://app.example.com/manifest.json", {
23
+ fetch: async () => new Response(null, { status: 302, headers: { location: "http://evil.test" } }),
24
+ resolveHost: async () => ["203.0.113.10"],
25
+ })).rejects.toThrow(/HTTPS/);
26
+ await expect(fetchProtectedManifest("https://app.example.com/manifest.json", {
27
+ fetch: async () => manifestResponse("text/plain"),
28
+ resolveHost: async () => ["203.0.113.10"],
29
+ })).rejects.toThrow(/content-type/);
30
+ await expect(fetchProtectedManifest("https://app.example.com/manifest.json", {
31
+ fetch: async () => manifestResponse(),
32
+ maxBytes: 8,
33
+ resolveHost: async () => ["203.0.113.10"],
34
+ })).rejects.toThrow(/maximum allowed size/);
35
+ });
36
+ it("rejects DNS rebinding between the public pre-check and the pinned connect", async () => {
37
+ let resolutions = 0;
38
+ await expect(fetchProtectedManifest("https://app.example.invalid/manifest.json", {
39
+ timeoutMs: 2000,
40
+ resolveHost: async () => {
41
+ resolutions += 1;
42
+ return resolutions === 1 ? ["203.0.113.10"] : ["10.0.0.7"];
43
+ },
44
+ })).rejects.toThrow(/private or reserved/);
45
+ expect(resolutions).toBeGreaterThanOrEqual(2);
46
+ });
47
+ });
@@ -0,0 +1,18 @@
1
+ import { type createCustomFieldsService } from "@voyant-travel/custom-fields";
2
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
3
+ import type { AppCredentialInput } from "./installation-service.js";
4
+ import { type AppInstallation, type AppRelease, appAccessCredentials, appExtensionInstallations, appGrants, appWebhookSubscriptions } from "./schema.js";
5
+ type CustomFieldsService = ReturnType<typeof createCustomFieldsService>;
6
+ export interface ReconciliationOptions {
7
+ customFields?: CustomFieldsService;
8
+ }
9
+ export declare function reconcileRelease(db: PostgresJsDatabase, installation: AppInstallation, release: AppRelease, actorId: string, action: string, options: ReconciliationOptions): Promise<void>;
10
+ export declare function reconcileGrants(db: PostgresJsDatabase, installation: AppInstallation, release: AppRelease, actorId: string, grantedOptionalScopes?: readonly string[]): Promise<void>;
11
+ export declare function newlyRequiredScopes(db: PostgresJsDatabase, installation: AppInstallation, release: AppRelease): Promise<string[]>;
12
+ export declare function rotateCredential(db: PostgresJsDatabase, installation: AppInstallation, credential: AppCredentialInput, actorId: string): Promise<void>;
13
+ export declare function deactivateRuntimeState(db: PostgresJsDatabase, installation: AppInstallation): Promise<void>;
14
+ export declare function deactivateResolvedRegistrations(db: PostgresJsDatabase, installation: AppInstallation): Promise<void>;
15
+ export declare function markAppDefinitionsInactive(db: PostgresJsDatabase, installation: AppInstallation, actorId: string): Promise<void>;
16
+ export declare function countInstallationRows(db: PostgresJsDatabase, table: typeof appGrants | typeof appAccessCredentials | typeof appExtensionInstallations | typeof appWebhookSubscriptions, installationId: string): Promise<number>;
17
+ export declare function audit(db: PostgresJsDatabase, installation: AppInstallation, actorId: string, kind: "lifecycle" | "grant" | "consent" | "credential" | "token" | "reconciliation" | "purge", action: string, details: Record<string, unknown>): Promise<void>;
18
+ export {};
@@ -0,0 +1,254 @@
1
+ import { createAppCustomFieldDefinitionOwner, customFieldDefinitions, } from "@voyant-travel/custom-fields";
2
+ import { ApiHttpError } from "@voyant-travel/hono";
3
+ import { and, eq, sql } from "drizzle-orm";
4
+ import { appAccessCredentials, appAuditEvents, appExtensionInstallations, appGrants, appOAuthRefreshTokens, appWebhookSubscriptions, } from "./schema.js";
5
+ export async function reconcileRelease(db, installation, release, actorId, action, options) {
6
+ const normalized = parseNormalizedRelease(release.normalizedRecord);
7
+ await reconcileCustomFields(db, installation, release, normalized, actorId, options);
8
+ await reconcileExtensions(db, installation, release, normalized);
9
+ await reconcileWebhooks(db, installation, release, normalized);
10
+ await audit(db, installation, actorId, "reconciliation", `manifest.${action}`, {
11
+ releaseId: release.id,
12
+ digest: release.manifestDigest,
13
+ customFields: normalized.customFields.length,
14
+ extensions: normalized.adminPages.length + normalized.slotExtensions.length,
15
+ webhooks: normalized.webhooks.length,
16
+ });
17
+ }
18
+ export async function reconcileGrants(db, installation, release, actorId, grantedOptionalScopes = []) {
19
+ const normalized = parseNormalizedRelease(release.normalizedRecord);
20
+ const optional = new Set(normalized.optionalScopes);
21
+ const grantedOptional = new Set(grantedOptionalScopes);
22
+ for (const scope of [...normalized.requestedScopes, ...normalized.optionalScopes].sort()) {
23
+ const isOptional = optional.has(scope);
24
+ const status = isOptional && !grantedOptional.has(scope) ? "optional" : "granted";
25
+ await db
26
+ .insert(appGrants)
27
+ .values({
28
+ installationId: installation.id,
29
+ scope,
30
+ optional: isOptional,
31
+ status,
32
+ grantedAt: status === "granted" ? new Date() : null,
33
+ })
34
+ .onConflictDoUpdate({
35
+ target: [appGrants.installationId, appGrants.scope],
36
+ set: { status, optional: isOptional, revokedAt: null },
37
+ });
38
+ }
39
+ await audit(db, installation, actorId, "grant", "grants.reconciled", {
40
+ requested: normalized.requestedScopes,
41
+ optional: normalized.optionalScopes,
42
+ });
43
+ }
44
+ export async function newlyRequiredScopes(db, installation, release) {
45
+ const normalized = parseNormalizedRelease(release.normalizedRecord);
46
+ const required = new Set(normalized.requestedScopes);
47
+ const existing = await db
48
+ .select({ scope: appGrants.scope })
49
+ .from(appGrants)
50
+ .where(and(eq(appGrants.installationId, installation.id), eq(appGrants.status, "granted")));
51
+ for (const grant of existing)
52
+ required.delete(grant.scope);
53
+ return [...required].sort();
54
+ }
55
+ export async function rotateCredential(db, installation, credential, actorId) {
56
+ await db
57
+ .update(appAccessCredentials)
58
+ .set({ status: "inactive", deactivatedAt: new Date() })
59
+ .where(and(eq(appAccessCredentials.installationId, installation.id), eq(appAccessCredentials.status, "active")));
60
+ const [{ generation = 0 } = {}] = await db
61
+ .select({ generation: sql `coalesce(max(${appAccessCredentials.generation}), 0)::int` })
62
+ .from(appAccessCredentials)
63
+ .where(eq(appAccessCredentials.installationId, installation.id));
64
+ await db.insert(appAccessCredentials).values({
65
+ installationId: installation.id,
66
+ generation: generation + 1,
67
+ credentialHash: credential.credentialHash,
68
+ encryptedMetadata: credential.encryptedMetadata,
69
+ expiresAt: credential.expiresAt ?? null,
70
+ });
71
+ await audit(db, installation, actorId, "credential", "credential.rotated", {
72
+ generation: generation + 1,
73
+ });
74
+ }
75
+ export async function deactivateRuntimeState(db, installation) {
76
+ const now = new Date();
77
+ await db
78
+ .update(appAccessCredentials)
79
+ .set({ status: "inactive", deactivatedAt: now })
80
+ .where(eq(appAccessCredentials.installationId, installation.id));
81
+ await db
82
+ .update(appOAuthRefreshTokens)
83
+ .set({ status: "revoked", revokedAt: now })
84
+ .where(eq(appOAuthRefreshTokens.installationId, installation.id));
85
+ await deactivateResolvedRegistrations(db, installation);
86
+ }
87
+ export async function deactivateResolvedRegistrations(db, installation) {
88
+ const now = new Date();
89
+ await db
90
+ .update(appExtensionInstallations)
91
+ .set({ status: "inactive", deactivatedAt: now })
92
+ .where(eq(appExtensionInstallations.installationId, installation.id));
93
+ await db
94
+ .update(appWebhookSubscriptions)
95
+ .set({ status: "inactive", deactivatedAt: now })
96
+ .where(eq(appWebhookSubscriptions.installationId, installation.id));
97
+ }
98
+ export async function markAppDefinitionsInactive(db, installation, actorId) {
99
+ await db
100
+ .update(customFieldDefinitions)
101
+ .set({ lifecycleState: "inactive", updatedAt: new Date() })
102
+ .where(and(eq(customFieldDefinitions.ownerKind, "app"), eq(customFieldDefinitions.ownerId, installation.appId), eq(customFieldDefinitions.namespace, installation.namespace)));
103
+ await audit(db, installation, actorId, "reconciliation", "custom_fields.inactive", {});
104
+ }
105
+ export async function countInstallationRows(db, table, installationId) {
106
+ const [row] = await db
107
+ .select({ count: sql `count(*)::int` })
108
+ .from(table)
109
+ .where(eq(table.installationId, installationId));
110
+ return row?.count ?? 0;
111
+ }
112
+ export async function audit(db, installation, actorId, kind, action, details) {
113
+ await db.insert(appAuditEvents).values({
114
+ installationId: installation.id,
115
+ appId: installation.appId,
116
+ deploymentId: installation.deploymentId,
117
+ actorId,
118
+ kind,
119
+ action,
120
+ details,
121
+ });
122
+ }
123
+ async function reconcileCustomFields(db, installation, release, normalized, actorId, options) {
124
+ if (!options.customFields)
125
+ return;
126
+ const owner = createAppCustomFieldDefinitionOwner({
127
+ appId: installation.appId,
128
+ namespace: installation.namespace,
129
+ provenance: { source: "app-manifest", releaseId: release.id, reconciledBy: actorId },
130
+ });
131
+ const existing = await options.customFields.listForOwner(db, owner, {
132
+ lifecycleState: "active",
133
+ limit: 100,
134
+ offset: 0,
135
+ });
136
+ const existingByIdentity = new Map(existing.data.map((definition) => [`${definition.entityType}:${definition.key}`, definition]));
137
+ const declared = new Set();
138
+ for (const field of normalized.customFields) {
139
+ const identity = `${field.entityType}:${field.key}`;
140
+ declared.add(identity);
141
+ const existingField = existingByIdentity.get(identity);
142
+ if (!existingField) {
143
+ await options.customFields.createForOwner(db, owner, field);
144
+ continue;
145
+ }
146
+ if (existingField.fieldType !== field.fieldType) {
147
+ throw new ApiHttpError("Custom-field type is immutable once installed", {
148
+ status: 409,
149
+ code: "app_custom_field_immutable_type",
150
+ });
151
+ }
152
+ if (field.isRequired && !existingField.isRequired) {
153
+ throw new ApiHttpError("Custom-field tightening requires migration validation", {
154
+ status: 409,
155
+ code: "app_custom_field_tightening_requires_validation",
156
+ });
157
+ }
158
+ await options.customFields.updateForOwner(db, owner, existingField.id, {
159
+ label: field.label,
160
+ isRequired: field.isRequired,
161
+ isSearchable: field.isSearchable,
162
+ isExportable: field.isExportable,
163
+ isInvoiceable: field.isInvoiceable,
164
+ options: field.options,
165
+ });
166
+ }
167
+ for (const [identity, definition] of existingByIdentity) {
168
+ if (!declared.has(identity)) {
169
+ await db
170
+ .update(customFieldDefinitions)
171
+ .set({ lifecycleState: "deprecated", updatedAt: new Date() })
172
+ .where(eq(customFieldDefinitions.id, definition.id));
173
+ }
174
+ }
175
+ }
176
+ function parseNormalizedRelease(value) {
177
+ if (!isNormalizedRelease(value)) {
178
+ throw new ApiHttpError("Stored app release manifest is not a normalized app release", {
179
+ status: 500,
180
+ code: "app_release_normalized_record_invalid",
181
+ });
182
+ }
183
+ return value;
184
+ }
185
+ function isNormalizedRelease(value) {
186
+ if (!value || typeof value !== "object")
187
+ return false;
188
+ const record = value;
189
+ return (record.schemaVersion === "voyant.app-release.normalized.v1" &&
190
+ typeof record.releaseVersion === "string" &&
191
+ typeof record.digest === "string" &&
192
+ Array.isArray(record.requestedScopes) &&
193
+ Array.isArray(record.optionalScopes) &&
194
+ Array.isArray(record.adminPages) &&
195
+ Array.isArray(record.slotExtensions) &&
196
+ Array.isArray(record.webhooks) &&
197
+ Array.isArray(record.customFields));
198
+ }
199
+ async function reconcileExtensions(db, installation, release, normalized) {
200
+ for (const page of normalized.adminPages) {
201
+ await upsertExtension(db, installation, release, `page:${page.key}`, page);
202
+ }
203
+ for (const extension of normalized.slotExtensions) {
204
+ await upsertExtension(db, installation, release, `slot:${extension.key}`, extension);
205
+ }
206
+ }
207
+ async function upsertExtension(db, installation, release, extensionKey, descriptor) {
208
+ await db
209
+ .insert(appExtensionInstallations)
210
+ .values({
211
+ installationId: installation.id,
212
+ releaseId: release.id,
213
+ extensionKey,
214
+ descriptor,
215
+ status: "active",
216
+ deactivatedAt: null,
217
+ })
218
+ .onConflictDoUpdate({
219
+ target: [appExtensionInstallations.installationId, appExtensionInstallations.extensionKey],
220
+ set: { releaseId: release.id, descriptor, status: "active", deactivatedAt: null },
221
+ });
222
+ }
223
+ async function reconcileWebhooks(db, installation, release, normalized) {
224
+ for (const webhook of normalized.webhooks) {
225
+ await db
226
+ .insert(appWebhookSubscriptions)
227
+ .values({
228
+ installationId: installation.id,
229
+ releaseId: release.id,
230
+ eventType: webhook.eventType,
231
+ eventVersion: webhook.eventVersion,
232
+ endpointUrl: webhook.endpointUrl,
233
+ status: "active",
234
+ failureCount: 0,
235
+ pausedAt: null,
236
+ deactivatedAt: null,
237
+ })
238
+ .onConflictDoUpdate({
239
+ target: [
240
+ appWebhookSubscriptions.installationId,
241
+ appWebhookSubscriptions.eventType,
242
+ appWebhookSubscriptions.eventVersion,
243
+ appWebhookSubscriptions.endpointUrl,
244
+ ],
245
+ set: {
246
+ releaseId: release.id,
247
+ status: "active",
248
+ failureCount: 0,
249
+ pausedAt: null,
250
+ deactivatedAt: null,
251
+ },
252
+ });
253
+ }
254
+ }