@voyant-travel/apps 0.9.0 → 0.10.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.
@@ -5,7 +5,7 @@ import { financeAppApiRuntimePort } from "@voyant-travel/finance-contracts/app-a
5
5
  import { createAppsAppApiRoutes } from "./app-api-routes.js";
6
6
  import { createAppOAuthService } from "./oauth-service.js";
7
7
  import { createAppsAdminRoutes } from "./routes.js";
8
- import { appsManagedAuthRuntimePort } from "./runtime-port.js";
8
+ import { appsManagedAuthRuntimePort, appsManagedMarketplaceRuntimePort, } from "./runtime-port.js";
9
9
  export const createAppsApiModule = defineGraphRuntimeFactory(async ({ getPort, getPorts, graph, hasPort }) => {
10
10
  const customFieldTargets = createCustomFieldTargetRegistry(graph.customFieldTargets ?? []);
11
11
  const customFieldValueLifecycles = await getPorts(customFieldValueLifecycleRuntimePort);
@@ -16,6 +16,15 @@ export const createAppsApiModule = defineGraphRuntimeFactory(async ({ getPort, g
16
16
  const managedAuth = hasPort(appsManagedAuthRuntimePort)
17
17
  ? await getPort(appsManagedAuthRuntimePort)
18
18
  : undefined;
19
+ const managedMarketplace = hasPort(appsManagedMarketplaceRuntimePort)
20
+ ? await getPort(appsManagedMarketplaceRuntimePort)
21
+ : undefined;
22
+ if (managedAuth &&
23
+ managedMarketplace &&
24
+ managedAuth.runtimeAudience !== managedMarketplace.deploymentId) {
25
+ throw new TypeError("apps.managed-auth runtimeAudience must match apps.managed-marketplace deploymentId.");
26
+ }
27
+ const deploymentId = managedAuth?.runtimeAudience ?? managedMarketplace?.deploymentId;
19
28
  const oauthOptions = managedAuth
20
29
  ? {
21
30
  accessCatalog: graph.accessCatalog,
@@ -29,6 +38,10 @@ export const createAppsApiModule = defineGraphRuntimeFactory(async ({ getPort, g
29
38
  module: { name: "apps" },
30
39
  adminRoutes: createAppsAdminRoutes({
31
40
  eventCatalog: graph.eventCatalog,
41
+ ...(deploymentId ? { deploymentId } : {}),
42
+ ...(managedMarketplace
43
+ ? { managedMarketplace: managedMarketplace.acquisitionResolver }
44
+ : {}),
32
45
  ...(oauthOptions ? { oauth: oauthOptions } : {}),
33
46
  ...(managedAuth
34
47
  ? {
@@ -1,6 +1,7 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { Hono } from "hono";
2
+ import { describe, expect, it, vi } from "vitest";
2
3
  import { createAppsApiModule } from "./api-runtime.js";
3
- import { appsManagedAuthRuntimePort } from "./runtime-port.js";
4
+ import { appsManagedAuthRuntimePort, appsManagedMarketplaceRuntimePort } from "./runtime-port.js";
4
5
  const graph = {
5
6
  providerSelections: {},
6
7
  customFieldTargets: [],
@@ -10,18 +11,25 @@ const graph = {
10
11
  references: [],
11
12
  setupSteps: [],
12
13
  };
13
- function context(managedAuth) {
14
+ function context(managedAuth, managedMarketplace) {
14
15
  return {
15
16
  unitId: "@voyant-travel/apps",
16
17
  projectConfig: {},
17
18
  getUnitProjectConfig: () => undefined,
18
19
  api: [],
19
20
  graph,
20
- runtimePorts: managedAuth ? { [appsManagedAuthRuntimePort.id]: managedAuth } : {},
21
- hasPort: (port) => port.id === appsManagedAuthRuntimePort.id && managedAuth !== undefined,
21
+ runtimePorts: {
22
+ ...(managedAuth ? { [appsManagedAuthRuntimePort.id]: managedAuth } : {}),
23
+ ...(managedMarketplace ? { [appsManagedMarketplaceRuntimePort.id]: managedMarketplace } : {}),
24
+ },
25
+ hasPort: (port) => (port.id === appsManagedAuthRuntimePort.id && managedAuth !== undefined) ||
26
+ (port.id === appsManagedMarketplaceRuntimePort.id && managedMarketplace !== undefined),
22
27
  getPort: async (port) => {
23
28
  if (port.id === appsManagedAuthRuntimePort.id && managedAuth)
24
29
  return managedAuth;
30
+ if (port.id === appsManagedMarketplaceRuntimePort.id && managedMarketplace) {
31
+ return managedMarketplace;
32
+ }
25
33
  throw new Error(`missing ${port.id}`);
26
34
  },
27
35
  getPorts: async () => [],
@@ -55,4 +63,63 @@ describe("createAppsApiModule", () => {
55
63
  token: "test-token",
56
64
  })).resolves.toBeNull();
57
65
  });
66
+ it("composes managed acquisition independently from OAuth authority", async () => {
67
+ const module = await createAppsApiModule(context(undefined, {
68
+ deploymentId: "deployment-marketplace-1",
69
+ acquisitionResolver: {
70
+ resolveAcquisitionIntent: async () => null,
71
+ createSetupHandoff: async () => ({
72
+ redirectUrl: "https://app.example.com/setup?code=opaque",
73
+ }),
74
+ notifyInstallationLifecycle: async () => undefined,
75
+ },
76
+ }));
77
+ expect(module.adminRoutes).toBeDefined();
78
+ expect(module.clientAuthenticated).toBeUndefined();
79
+ if (!module.adminRoutes)
80
+ throw new Error("apps admin routes are required");
81
+ const transaction = vi.fn(async () => {
82
+ throw new Error("installation transaction reached");
83
+ });
84
+ const app = new Hono();
85
+ app.onError((error, c) => c.json({ error: error.message }, 500));
86
+ app.use("*", async (c, next) => {
87
+ c.set("db", { transaction });
88
+ await next();
89
+ });
90
+ app.route("/", module.adminRoutes);
91
+ const response = await app.request("/install", {
92
+ method: "POST",
93
+ headers: { "content-type": "application/json" },
94
+ body: JSON.stringify({
95
+ appId: "app-1",
96
+ releaseId: "release-1",
97
+ actorId: "actor-1",
98
+ }),
99
+ });
100
+ expect(response.status).toBe(500);
101
+ await expect(response.json()).resolves.toEqual({
102
+ error: "installation transaction reached",
103
+ });
104
+ expect(transaction).toHaveBeenCalledOnce();
105
+ });
106
+ it("rejects conflicting managed deployment identities", async () => {
107
+ await expect(createAppsApiModule(context({
108
+ runtimeAudience: "deployment-auth-1",
109
+ installationAuthority: {
110
+ workloadEnvironmentId: "workload-environment-1",
111
+ resolveInstallationContract: async () => ({ contractGeneration: 1 }),
112
+ },
113
+ sessionTokenSigningSecret: "s".repeat(32),
114
+ }, {
115
+ deploymentId: "deployment-marketplace-2",
116
+ acquisitionResolver: {
117
+ resolveAcquisitionIntent: async () => null,
118
+ createSetupHandoff: async () => ({
119
+ redirectUrl: "https://app.example.com/setup?code=opaque",
120
+ }),
121
+ notifyInstallationLifecycle: async () => undefined,
122
+ },
123
+ }))).rejects.toThrow(/must match/);
124
+ });
58
125
  });
@@ -10,6 +10,17 @@ describe("app manifest compiler", () => {
10
10
  });
11
11
  expect(parsed.data.storesSecrets).toBe(true);
12
12
  });
13
+ it("admits an explicit HTTPS managed lifecycle endpoint", () => {
14
+ const parsed = appManifestSchema.parse({
15
+ ...validManifest,
16
+ urls: { ...validManifest.urls, lifecycle: "https://app.example.com/lifecycle" },
17
+ });
18
+ expect(parsed.urls.lifecycle).toBe("https://app.example.com/lifecycle");
19
+ expect(() => appManifestSchema.parse({
20
+ ...validManifest,
21
+ urls: { ...validManifest.urls, lifecycle: "http://app.example.com/lifecycle" },
22
+ })).toThrow(/https/i);
23
+ });
13
24
  it("accepts a closed v1 manifest and produces a stable digest", () => {
14
25
  const first = compileAppManifest(validManifest);
15
26
  const second = compileAppManifest({
@@ -127,6 +127,7 @@ export declare const appManifestSchema: z.ZodPipe<z.ZodRecord<z.ZodString, z.Zod
127
127
  }, z.core.$strict>;
128
128
  urls: z.ZodObject<{
129
129
  setup: z.ZodOptional<z.ZodString>;
130
+ lifecycle: z.ZodOptional<z.ZodString>;
130
131
  health: z.ZodString;
131
132
  launch: z.ZodString;
132
133
  privacy: z.ZodString;
@@ -220,6 +221,7 @@ export declare const appOAuthAuthorizeQuerySchema: z.ZodObject<{
220
221
  release_id: z.ZodString;
221
222
  redirect_uri: z.ZodString;
222
223
  state: z.ZodString;
224
+ nonce: z.ZodOptional<z.ZodString>;
223
225
  code_challenge: z.ZodString;
224
226
  code_challenge_method: z.ZodLiteral<"S256">;
225
227
  actor_id: z.ZodOptional<z.ZodString>;
package/dist/contracts.js CHANGED
@@ -151,6 +151,7 @@ export const appManifestSchema = manifestDisallowedKeySchema.pipe(z
151
151
  urls: z
152
152
  .object({
153
153
  setup: httpsUrlSchema.optional(),
154
+ lifecycle: httpsUrlSchema.optional(),
154
155
  health: httpsUrlSchema,
155
156
  launch: httpsUrlSchema,
156
157
  privacy: httpsUrlSchema,
@@ -269,6 +270,8 @@ export const appOAuthAuthorizeQuerySchema = z
269
270
  release_id: z.string().trim().min(1),
270
271
  redirect_uri: z.string().url(),
271
272
  state: z.string().trim().min(1),
273
+ /** Optional app-generated ceremony binding echoed to the redirect URI. */
274
+ nonce: z.string().trim().min(43).max(128).optional(),
272
275
  code_challenge: z.string().trim().min(1),
273
276
  code_challenge_method: z.literal("S256"),
274
277
  /** Deprecated input; the route derives the actor from the staff session. */
package/dist/index.d.ts CHANGED
@@ -10,6 +10,7 @@ export * from "./ingestion.js";
10
10
  export * from "./installation-read-model.js";
11
11
  export * from "./installation-service.js";
12
12
  export * from "./locale-resolution.js";
13
+ export * from "./marketplace-acquisition.js";
13
14
  export * from "./oauth-crypto.js";
14
15
  export * from "./oauth-service.js";
15
16
  export { createAppsAdminRoutes } from "./routes.js";
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ export * from "./ingestion.js";
10
10
  export * from "./installation-read-model.js";
11
11
  export * from "./installation-service.js";
12
12
  export * from "./locale-resolution.js";
13
+ export * from "./marketplace-acquisition.js";
13
14
  export * from "./oauth-crypto.js";
14
15
  export * from "./oauth-service.js";
15
16
  export { createAppsAdminRoutes } from "./routes.js";
@@ -1,7 +1,9 @@
1
1
  import { handleApiError } from "@voyant-travel/hono";
2
+ import { eq } from "drizzle-orm";
2
3
  import { Hono } from "hono";
3
- import { beforeAll, beforeEach, describe, expect, it } from "vitest";
4
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
4
5
  import { createAppsAdminRoutes } from "./routes.js";
6
+ import { apps } from "./schema.js";
5
7
  import { createAppsService } from "./service.js";
6
8
  import { validManifest } from "./test-fixtures.js";
7
9
  const DB_AVAILABLE = !!process.env.TEST_DATABASE_URL;
@@ -158,4 +160,36 @@ describe.skipIf(!DB_AVAILABLE)("apps installation admin routes", () => {
158
160
  expect(body.data.installation.status).toBe("active");
159
161
  expect(body.data.outcome).toBe("created");
160
162
  });
163
+ it("notifies managed authority after a Marketplace uninstall", async () => {
164
+ const { appId, releaseV1 } = await seedAppWithReleases();
165
+ await db.update(apps).set({ distribution: "marketplace" }).where(eq(apps.id, appId));
166
+ const notifyInstallationLifecycle = vi.fn(async () => undefined);
167
+ const managedApp = new Hono();
168
+ managedApp.onError((err, c) => handleApiError(err, c));
169
+ managedApp.use("*", async (c, next) => {
170
+ c.set("db", db);
171
+ await next();
172
+ });
173
+ managedApp.route("/", createAppsAdminRoutes({
174
+ deploymentId: DEPLOYMENT_ID,
175
+ managedMarketplace: {
176
+ resolveAcquisitionIntent: async () => null,
177
+ createSetupHandoff: async () => ({
178
+ redirectUrl: "https://app.example.com/setup?code=opaque",
179
+ }),
180
+ notifyInstallationLifecycle,
181
+ },
182
+ }));
183
+ const installed = await managedApp.request("/install", json({ appId, releaseId: releaseV1.id, actorId: "actor_1" }));
184
+ const installationId = (await installed.json())
185
+ .data.installation.id;
186
+ const response = await managedApp.request(`/installations/${installationId}/uninstall`, json({ actorId: "actor_1" }));
187
+ expect(response.status).toBe(200);
188
+ expect(notifyInstallationLifecycle).toHaveBeenCalledWith({
189
+ event: "uninstalled",
190
+ installationId,
191
+ appId,
192
+ releaseId: releaseV1.id,
193
+ });
194
+ });
161
195
  });
@@ -106,7 +106,7 @@ export declare function createAppInstallationService(options?: AppInstallationSe
106
106
  uninstalledAt: Date | null;
107
107
  purgedAt: Date | null;
108
108
  };
109
- outcome: "reinstalled" | "created";
109
+ outcome: "created" | "reinstalled";
110
110
  }>;
111
111
  upgrade: (db: PostgresJsDatabase, input: UpgradeAppInput) => Promise<{
112
112
  installation: {
@@ -0,0 +1,93 @@
1
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
2
+ import { z } from "zod";
3
+ import { type CompileAppManifestOptions } from "./compiler.js";
4
+ /**
5
+ * Provider-neutral, host-verified input to the deployment-local app registry.
6
+ * A managed host adapts its private catalog model to this closed contract only
7
+ * after it has authenticated the deployment and verified artifact provenance.
8
+ */
9
+ export declare const hostVerifiedMarketplaceAcquisitionSchema: z.ZodObject<{
10
+ schemaVersion: z.ZodLiteral<"voyant.runtime-marketplace-acquisition.v1">;
11
+ acquisitionId: z.ZodString;
12
+ app: z.ZodObject<{
13
+ id: z.ZodString;
14
+ ownerId: z.ZodString;
15
+ displayName: z.ZodString;
16
+ slug: z.ZodString;
17
+ redirectUris: z.ZodArray<z.ZodString>;
18
+ oauthClient: z.ZodObject<{
19
+ method: z.ZodLiteral<"client_secret_post">;
20
+ secretSha256: z.ZodString;
21
+ }, z.core.$strict>;
22
+ }, z.core.$strict>;
23
+ release: z.ZodObject<{
24
+ id: z.ZodString;
25
+ manifest: z.ZodUnknown;
26
+ digest: z.ZodString;
27
+ signature: z.ZodOptional<z.ZodString>;
28
+ provenance: z.ZodRecord<z.ZodString, z.ZodUnknown>;
29
+ assetInventory: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
30
+ }, z.core.$strict>;
31
+ }, z.core.$strict>;
32
+ export type HostVerifiedMarketplaceAcquisition = z.infer<typeof hostVerifiedMarketplaceAcquisitionSchema>;
33
+ export declare const resolveMarketplaceInstallIntentSchema: z.ZodObject<{
34
+ intent: z.ZodString;
35
+ }, z.core.$strict>;
36
+ export declare const marketplaceInstallIntentResultSchema: z.ZodObject<{
37
+ data: z.ZodObject<{
38
+ appId: z.ZodString;
39
+ releaseId: z.ZodString;
40
+ acquisitionId: z.ZodString;
41
+ created: z.ZodBoolean;
42
+ }, z.core.$strict>;
43
+ }, z.core.$strip>;
44
+ export declare const marketplaceSetupHandoffResultSchema: z.ZodObject<{
45
+ data: z.ZodObject<{
46
+ redirectUrl: z.ZodString;
47
+ }, z.core.$strict>;
48
+ }, z.core.$strip>;
49
+ export declare const MARKETPLACE_SETUP_ASSERTION_TYPE: "voyant-marketplace-setup+jwt";
50
+ /** Claims signed by the managed host and verified by the remote app backend. */
51
+ export declare const marketplaceSetupAssertionClaimsSchema: z.ZodObject<{
52
+ schemaVersion: z.ZodLiteral<"voyant.marketplace-setup-assertion.v1">;
53
+ iss: z.ZodString;
54
+ aud: z.ZodString;
55
+ sub: z.ZodString;
56
+ jti: z.ZodString;
57
+ iat: z.ZodNumber;
58
+ exp: z.ZodNumber;
59
+ installationId: z.ZodString;
60
+ appId: z.ZodString;
61
+ releaseId: z.ZodString;
62
+ authorizationUrl: z.ZodString;
63
+ tokenUrl: z.ZodString;
64
+ redirectUri: z.ZodString;
65
+ }, z.core.$strict>;
66
+ export type MarketplaceSetupAssertionClaims = z.infer<typeof marketplaceSetupAssertionClaimsSchema>;
67
+ export interface MarketplaceInstallIntentResult {
68
+ appId: string;
69
+ releaseId: string;
70
+ acquisitionId: string;
71
+ created: boolean;
72
+ }
73
+ export interface MarketplaceAcquisitionServiceOptions extends CompileAppManifestOptions {
74
+ resolveAcquisitionIntent(input: {
75
+ intent: string;
76
+ }): Promise<HostVerifiedMarketplaceAcquisition | null>;
77
+ createSetupHandoff(input: {
78
+ installationId: string;
79
+ appId: string;
80
+ releaseId: string;
81
+ }): Promise<{
82
+ redirectUrl: string;
83
+ }>;
84
+ }
85
+ export declare function createMarketplaceAcquisitionService(options: MarketplaceAcquisitionServiceOptions): {
86
+ resolveAndAcquire(db: PostgresJsDatabase, input: {
87
+ intent: string;
88
+ actorId: string;
89
+ }): Promise<MarketplaceInstallIntentResult>;
90
+ createSetupHandoff(db: PostgresJsDatabase, installationId: string): Promise<{
91
+ redirectUrl: string;
92
+ }>;
93
+ };