@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,178 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { appRedirectUris, appReleaseArtifacts, appReleaseLocalizations, appReleases, apps, } from "./schema.js";
3
+ import { createAppsService } from "./service.js";
4
+ import { validManifest } from "./test-fixtures.js";
5
+ function postgresStub(implementation) {
6
+ const db = Object.create(null);
7
+ Object.assign(db, implementation);
8
+ if (!("transaction" in db)) {
9
+ Object.assign(db, {
10
+ transaction: async (callback) => callback(db),
11
+ });
12
+ }
13
+ return db;
14
+ }
15
+ function createRegistryDb() {
16
+ const rows = {
17
+ apps: [],
18
+ releases: [],
19
+ artifacts: [],
20
+ localizations: [],
21
+ redirectUris: [],
22
+ };
23
+ let appSequence = 0;
24
+ let releaseSequence = 0;
25
+ const db = postgresStub({
26
+ insert: (table) => ({
27
+ values: (value) => {
28
+ const values = Array.isArray(value) ? value : [value];
29
+ const insertRows = () => {
30
+ if (table === apps) {
31
+ const row = { id: `app_${++appSequence}`, ...values[0] };
32
+ if (rows.apps.some((existing) => existing.platformNamespace === row.platformNamespace)) {
33
+ return [];
34
+ }
35
+ rows.apps.push(row);
36
+ return [row];
37
+ }
38
+ if (table === appReleases) {
39
+ const row = { id: `release_${++releaseSequence}`, ...values[0] };
40
+ const duplicate = rows.releases.find((existing) => existing.appId === row.appId &&
41
+ existing.manifestDigest === row.manifestDigest);
42
+ if (duplicate)
43
+ return [];
44
+ rows.releases.push(row);
45
+ return [row];
46
+ }
47
+ if (table === appReleaseArtifacts)
48
+ rows.artifacts.push(...values);
49
+ if (table === appReleaseLocalizations)
50
+ rows.localizations.push(...values);
51
+ if (table === appRedirectUris)
52
+ rows.redirectUris.push(...values);
53
+ return values;
54
+ };
55
+ return {
56
+ onConflictDoNothing: () => ({ returning: async () => insertRows() }),
57
+ returning: async () => insertRows(),
58
+ };
59
+ },
60
+ }),
61
+ select: () => ({
62
+ from: (table) => ({
63
+ where: () => ({
64
+ for: () => ({
65
+ limit: async () => (table === apps ? rows.apps.slice(0, 1) : []),
66
+ }),
67
+ limit: async () => (table === appReleases ? rows.releases.slice(0, 1) : []),
68
+ }),
69
+ }),
70
+ }),
71
+ });
72
+ return { db, rows };
73
+ }
74
+ describe("apps service", () => {
75
+ it("assigns immutable platform app ids and reserved namespaces independent of names", async () => {
76
+ const { db, rows } = createRegistryDb();
77
+ const service = createAppsService();
78
+ const first = await service.createCustomApp(db, {
79
+ ownerId: "owner_1",
80
+ displayName: "Acme Sync",
81
+ slug: "acme-sync",
82
+ redirectUris: [],
83
+ createdBy: "user_1",
84
+ });
85
+ const second = await service.createCustomApp(db, {
86
+ ownerId: "owner_1",
87
+ displayName: "Acme Sync",
88
+ slug: "acme-sync",
89
+ redirectUris: [],
90
+ createdBy: "user_1",
91
+ });
92
+ expect(first.id).not.toBe(second.id);
93
+ expect(first.platformNamespace).toMatch(/^app--[a-f0-9]+$/);
94
+ expect(second.platformNamespace).toMatch(/^app--[a-f0-9]+$/);
95
+ expect(first.platformNamespace).not.toBe(second.platformNamespace);
96
+ expect(rows.apps).toHaveLength(2);
97
+ });
98
+ it("creates immutable releases and treats the same digest as idempotent", async () => {
99
+ const { db, rows } = createRegistryDb();
100
+ const service = createAppsService();
101
+ const app = await service.createCustomApp(db, {
102
+ ownerId: "owner_1",
103
+ displayName: "Acme Sync",
104
+ slug: "acme-sync",
105
+ redirectUris: [],
106
+ createdBy: "user_1",
107
+ });
108
+ const first = await service.releaseFromUpload(db, app.id, {
109
+ manifest: validManifest,
110
+ createdBy: "user_1",
111
+ provenance: { source: "test" },
112
+ });
113
+ const second = await service.releaseFromUpload(db, app.id, {
114
+ manifest: validManifest,
115
+ createdBy: "user_1",
116
+ provenance: { source: "test" },
117
+ });
118
+ expect(first.created).toBe(true);
119
+ expect(second.created).toBe(false);
120
+ expect(first.release.id).toBe(second.release.id);
121
+ expect(rows.releases).toHaveLength(1);
122
+ expect(rows.artifacts).toHaveLength(1);
123
+ expect(rows.localizations.length).toBeGreaterThan(0);
124
+ expect(Object.keys(service)).not.toContain("updateRelease");
125
+ });
126
+ it("rejects reusing a release version with different content", async () => {
127
+ const { db, rows } = createRegistryDb();
128
+ const service = createAppsService();
129
+ const app = await service.createCustomApp(db, {
130
+ ownerId: "owner_1",
131
+ displayName: "Acme Sync",
132
+ slug: "acme-sync",
133
+ redirectUris: [],
134
+ createdBy: "user_1",
135
+ });
136
+ await service.releaseFromUpload(db, app.id, {
137
+ manifest: validManifest,
138
+ createdBy: "user_1",
139
+ provenance: { source: "test" },
140
+ });
141
+ await expect(service.releaseFromUpload(db, app.id, {
142
+ manifest: {
143
+ ...validManifest,
144
+ scopes: { requested: ["bookings:read", "invoices:read"], optional: [] },
145
+ },
146
+ createdBy: "user_1",
147
+ provenance: { source: "test" },
148
+ })).rejects.toMatchObject({ status: 409 });
149
+ expect(rows.releases).toHaveLength(1);
150
+ });
151
+ it("fetch ingestion stores only the validated snapshot", async () => {
152
+ const { db, rows } = createRegistryDb();
153
+ const fetcher = vi.fn(async () => new Response(JSON.stringify(validManifest), {
154
+ headers: { "content-type": "application/json" },
155
+ }));
156
+ const service = createAppsService({
157
+ fetch: fetcher,
158
+ resolveHost: async () => ["203.0.113.10"],
159
+ });
160
+ const app = await service.createCustomApp(db, {
161
+ ownerId: "owner_1",
162
+ displayName: "Acme Sync",
163
+ slug: "acme-sync",
164
+ redirectUris: [],
165
+ createdBy: "user_1",
166
+ });
167
+ await service.releaseFromFetch(db, app.id, {
168
+ manifestUrl: "https://app.example.com/manifest.json",
169
+ createdBy: "user_1",
170
+ });
171
+ expect(rows.releases[0]).toMatchObject({
172
+ appId: app.id,
173
+ manifestSchemaVersion: "voyant.app-manifest.v1",
174
+ });
175
+ expect(rows.releases[0]?.manifestSnapshot).not.toHaveProperty("scripts");
176
+ expect(rows.artifacts[0]?.provenance).toMatchObject({ source: "https-manifest" });
177
+ });
178
+ });
@@ -0,0 +1,80 @@
1
+ export declare const validManifest: {
2
+ schemaVersion: "voyant.app-manifest.v1";
3
+ releaseVersion: string;
4
+ apiCompatibility: {
5
+ min: string;
6
+ max: string;
7
+ };
8
+ scopes: {
9
+ requested: string[];
10
+ optional: string[];
11
+ };
12
+ admin: {
13
+ pages: {
14
+ key: string;
15
+ titleKey: string;
16
+ path: string;
17
+ entryUrl: string;
18
+ }[];
19
+ slotExtensions: {
20
+ key: string;
21
+ titleKey: string;
22
+ version: string;
23
+ extensionApi: string;
24
+ entryUrl: string;
25
+ slots: "booking.details.header"[];
26
+ }[];
27
+ };
28
+ webhooks: {
29
+ eventType: string;
30
+ eventVersion: string;
31
+ endpointUrl: string;
32
+ }[];
33
+ customFields: {
34
+ entityType: string;
35
+ key: string;
36
+ label: string;
37
+ fieldType: "text";
38
+ isRequired: false;
39
+ isSearchable: false;
40
+ isExportable: true;
41
+ isInvoiceable: false;
42
+ options: null;
43
+ dataClassification: "internal";
44
+ }[];
45
+ locales: {
46
+ default: string;
47
+ supported: string[];
48
+ host: {
49
+ "en-US": {
50
+ name: string;
51
+ summary: string;
52
+ navigation: {
53
+ settings: string;
54
+ };
55
+ extensions: {
56
+ "booking-panel": string;
57
+ };
58
+ setup: {};
59
+ };
60
+ "ro-RO": {
61
+ name: string;
62
+ summary: string;
63
+ navigation: {};
64
+ extensions: {};
65
+ setup: {};
66
+ };
67
+ };
68
+ };
69
+ urls: {
70
+ health: string;
71
+ launch: string;
72
+ privacy: string;
73
+ support: string;
74
+ };
75
+ data: {
76
+ classifications: "internal"[];
77
+ retention: string;
78
+ storesSecrets: false;
79
+ };
80
+ };
@@ -0,0 +1,78 @@
1
+ export const validManifest = {
2
+ schemaVersion: "voyant.app-manifest.v1",
3
+ releaseVersion: "1.0.0",
4
+ apiCompatibility: { min: "2026-07-01", max: "2026-12-31" },
5
+ scopes: { requested: ["bookings:read"], optional: ["invoices:read"] },
6
+ admin: {
7
+ pages: [
8
+ {
9
+ key: "settings",
10
+ titleKey: "settings.title",
11
+ path: "/settings",
12
+ entryUrl: "https://app.example.com/admin/settings",
13
+ },
14
+ ],
15
+ slotExtensions: [
16
+ {
17
+ key: "booking-panel",
18
+ titleKey: "booking.panel.title",
19
+ version: "1.0.0",
20
+ extensionApi: "^1",
21
+ entryUrl: "https://app.example.com/admin/booking-panel",
22
+ slots: ["booking.details.header"],
23
+ },
24
+ ],
25
+ },
26
+ webhooks: [
27
+ {
28
+ eventType: "booking.created",
29
+ eventVersion: "1.0.0",
30
+ endpointUrl: "https://app.example.com/webhooks/voyant",
31
+ },
32
+ ],
33
+ customFields: [
34
+ {
35
+ entityType: "booking",
36
+ key: "external_id",
37
+ label: "External ID",
38
+ fieldType: "text",
39
+ isRequired: false,
40
+ isSearchable: false,
41
+ isExportable: true,
42
+ isInvoiceable: false,
43
+ options: null,
44
+ dataClassification: "internal",
45
+ },
46
+ ],
47
+ locales: {
48
+ default: "en-US",
49
+ supported: ["en-US", "ro-RO"],
50
+ host: {
51
+ "en-US": {
52
+ name: "Example App",
53
+ summary: "Synchronizes example records.",
54
+ navigation: { settings: "Example" },
55
+ extensions: { "booking-panel": "Example panel" },
56
+ setup: {},
57
+ },
58
+ "ro-RO": {
59
+ name: "Aplicatie Exemplu",
60
+ summary: "Sincronizeaza inregistrari exemplu.",
61
+ navigation: {},
62
+ extensions: {},
63
+ setup: {},
64
+ },
65
+ },
66
+ },
67
+ urls: {
68
+ health: "https://app.example.com/health",
69
+ launch: "https://app.example.com/launch",
70
+ privacy: "https://app.example.com/privacy",
71
+ support: "https://app.example.com/support",
72
+ },
73
+ data: {
74
+ classifications: ["internal"],
75
+ retention: "Retained until the operator removes the app-owned records.",
76
+ storesSecrets: false,
77
+ },
78
+ };
@@ -0,0 +1,2 @@
1
+ export declare const appsVoyantModule: import("@voyant-travel/core").VoyantGraphUnitManifest;
2
+ export default appsVoyantModule;
package/dist/voyant.js ADDED
@@ -0,0 +1,115 @@
1
+ import { defineModule } from "@voyant-travel/core/project";
2
+ const appInstallationLifecyclePayloadSchema = {
3
+ type: "object",
4
+ additionalProperties: false,
5
+ required: ["appId", "installationId", "deploymentId"],
6
+ properties: {
7
+ appId: { type: "string" },
8
+ installationId: { type: "string" },
9
+ deploymentId: { type: "string" },
10
+ },
11
+ };
12
+ export const appsVoyantModule = defineModule({
13
+ id: "@voyant-travel/apps",
14
+ packageName: "@voyant-travel/apps",
15
+ localId: "apps",
16
+ api: [
17
+ {
18
+ id: "@voyant-travel/apps#api.admin",
19
+ surface: "admin",
20
+ mount: "apps",
21
+ resource: "apps",
22
+ transactional: true,
23
+ runtime: {
24
+ entry: "@voyant-travel/apps/api-runtime",
25
+ export: "createAppsApiModule",
26
+ },
27
+ },
28
+ ],
29
+ schema: [
30
+ {
31
+ id: "@voyant-travel/apps#schema",
32
+ source: "@voyant-travel/apps/schema",
33
+ },
34
+ ],
35
+ migrations: [
36
+ {
37
+ id: "@voyant-travel/apps#migrations",
38
+ source: "./migrations",
39
+ },
40
+ ],
41
+ events: [
42
+ {
43
+ id: "@voyant-travel/apps#event.installation-active",
44
+ eventType: "app.installation.active",
45
+ version: "1.0.0",
46
+ payloadSchema: appInstallationLifecyclePayloadSchema,
47
+ visibility: "internal",
48
+ audit: { sourceModule: "apps", category: "internal" },
49
+ },
50
+ {
51
+ id: "@voyant-travel/apps#event.installation-upgraded",
52
+ eventType: "app.installation.upgraded",
53
+ version: "1.0.0",
54
+ payloadSchema: appInstallationLifecyclePayloadSchema,
55
+ visibility: "internal",
56
+ audit: { sourceModule: "apps", category: "internal" },
57
+ },
58
+ {
59
+ id: "@voyant-travel/apps#event.installation-upgrade-pending",
60
+ eventType: "app.installation.upgrade_pending",
61
+ version: "1.0.0",
62
+ payloadSchema: appInstallationLifecyclePayloadSchema,
63
+ visibility: "internal",
64
+ audit: { sourceModule: "apps", category: "internal" },
65
+ },
66
+ {
67
+ id: "@voyant-travel/apps#event.installation-paused",
68
+ eventType: "app.installation.paused",
69
+ version: "1.0.0",
70
+ payloadSchema: appInstallationLifecyclePayloadSchema,
71
+ visibility: "internal",
72
+ audit: { sourceModule: "apps", category: "internal" },
73
+ },
74
+ {
75
+ id: "@voyant-travel/apps#event.installation-uninstalled",
76
+ eventType: "app.installation.uninstalled",
77
+ version: "1.0.0",
78
+ payloadSchema: appInstallationLifecyclePayloadSchema,
79
+ visibility: "internal",
80
+ audit: { sourceModule: "apps", category: "internal" },
81
+ },
82
+ ],
83
+ access: {
84
+ resources: [
85
+ {
86
+ id: "@voyant-travel/apps#access.apps",
87
+ resource: "apps",
88
+ label: "Apps",
89
+ description: "Administer app registrations and immutable app releases.",
90
+ actions: [
91
+ {
92
+ action: "read",
93
+ label: "View apps",
94
+ description: "View app registrations and releases.",
95
+ },
96
+ {
97
+ action: "write",
98
+ label: "Administer apps",
99
+ description: "Create custom app registrations and immutable app releases.",
100
+ sensitive: true,
101
+ },
102
+ ],
103
+ },
104
+ ],
105
+ },
106
+ lifecycle: { uninstall: { default: "retain-data", purge: "not-supported" } },
107
+ meta: {
108
+ ownership: "package",
109
+ agentTools: {
110
+ posture: "not-applicable",
111
+ rationale: "The app registry is an authenticated admin API surface; this module does not expose agent Tools.",
112
+ },
113
+ },
114
+ });
115
+ export default appsVoyantModule;
@@ -0,0 +1,69 @@
1
+ import type { EventEnvelope } from "@voyant-travel/core";
2
+ import { type ExternalWebhookEventContract, type WebhookDeliveryStore, type WebhookEnqueueOutcome, type WebhookSigningKey } from "@voyant-travel/webhook-delivery";
3
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
4
+ export interface AppWebhookDeliveryOptions {
5
+ contracts: readonly ExternalWebhookEventContract[];
6
+ resolveSigningKey(input: {
7
+ appId: string;
8
+ installationId: string;
9
+ }): Promise<WebhookSigningKey>;
10
+ terminalFailureThreshold?: number;
11
+ now?: () => Date;
12
+ }
13
+ export declare function createAppWebhookDeliveryStore(db: PostgresJsDatabase, options: Omit<AppWebhookDeliveryOptions, "contracts">): WebhookDeliveryStore;
14
+ export declare function createAppWebhookEventQueue(db: PostgresJsDatabase, options: AppWebhookDeliveryOptions): import("@voyant-travel/webhook-delivery").SelectedExternalWebhookQueue;
15
+ export declare function enqueueAppWebhookEvent(db: PostgresJsDatabase, event: EventEnvelope, options: AppWebhookDeliveryOptions): Promise<WebhookEnqueueOutcome[]>;
16
+ export declare function listAppWebhookHealth(db: PostgresJsDatabase, installationId: string): Promise<{
17
+ data: {
18
+ id: string;
19
+ installationId: string;
20
+ releaseId: string;
21
+ eventType: string;
22
+ eventVersion: string;
23
+ endpointUrl: string;
24
+ status: "active" | "inactive" | "failed";
25
+ externalSubscriptionId: string | null;
26
+ signingKeyId: string | null;
27
+ lastDeliveryAt: Date | null;
28
+ failureCount: number;
29
+ pausedAt: Date | null;
30
+ installedAt: Date;
31
+ deactivatedAt: Date | null;
32
+ }[];
33
+ }>;
34
+ export declare function replayAppWebhookDelivery(db: PostgresJsDatabase, input: {
35
+ deliveryId: string;
36
+ actorId: string;
37
+ signingKey: WebhookSigningKey;
38
+ }): Promise<{
39
+ id: string;
40
+ sourceModule: string;
41
+ sourceEvent: string;
42
+ sourceEntityModule: string | null;
43
+ sourceEntityId: string | null;
44
+ subscriptionId: string | null;
45
+ targetUrl: string;
46
+ targetKind: string | null;
47
+ targetRef: string | null;
48
+ requestMethod: string;
49
+ requestHeaders: Record<string, string> | null;
50
+ requestBodyHash: string | null;
51
+ requestBodyExcerpt: string | null;
52
+ requestPayload: Record<string, unknown> | null;
53
+ deliveryContract: Record<string, unknown> | null;
54
+ responseStatus: number | null;
55
+ responseHeaders: Record<string, string> | null;
56
+ responseBodyExcerpt: string | null;
57
+ attemptNumber: number;
58
+ parentDeliveryId: string | null;
59
+ idempotencyKey: string | null;
60
+ status: "pending" | "failed" | "succeeded" | "in_flight" | "abandoned";
61
+ scheduledFor: Date | null;
62
+ startedAt: Date | null;
63
+ finishedAt: Date | null;
64
+ durationMs: number | null;
65
+ errorClass: "timeout" | "network" | "4xx" | "5xx" | "adapter_error" | "rate_limited" | null;
66
+ errorMessage: string | null;
67
+ createdAt: Date;
68
+ updatedAt: Date;
69
+ }>;