@voyant-travel/apps 0.2.0 → 0.3.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.
@@ -0,0 +1,29 @@
1
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
2
+ export interface AppInstallationAccessInput {
3
+ installationId: string;
4
+ requiredScopes?: readonly string[];
5
+ }
6
+ export declare function assertActiveAppInstallationAccess(db: PostgresJsDatabase, input: AppInstallationAccessInput): Promise<{
7
+ id: string;
8
+ appId: string;
9
+ deploymentId: string;
10
+ releaseId: string;
11
+ status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
12
+ namespace: string;
13
+ installedBy: string;
14
+ credentialGeneration: number;
15
+ updatePolicy: "manual" | "compatible" | "patch" | "pinned";
16
+ lastCompatibleReleaseCheckAt: Date | null;
17
+ pendingReleaseId: string | null;
18
+ pendingReason: string | null;
19
+ installedAt: Date;
20
+ authorizedAt: Date | null;
21
+ activatedAt: Date | null;
22
+ pausedAt: Date | null;
23
+ degradedAt: Date | null;
24
+ revokedAt: Date | null;
25
+ uninstalledAt: Date | null;
26
+ purgedAt: Date | null;
27
+ createdAt: Date;
28
+ updatedAt: Date;
29
+ }>;
@@ -0,0 +1,33 @@
1
+ import { ApiHttpError } from "@voyant-travel/hono";
2
+ import { and, eq } from "drizzle-orm";
3
+ import { appGrants, appInstallations } from "./schema.js";
4
+ export async function assertActiveAppInstallationAccess(db, input) {
5
+ const [installation] = await db
6
+ .select()
7
+ .from(appInstallations)
8
+ .where(eq(appInstallations.id, input.installationId))
9
+ .limit(1);
10
+ if (installation?.status !== "active") {
11
+ throw new ApiHttpError("App installation is not active", {
12
+ status: 403,
13
+ code: "app_installation_not_active",
14
+ });
15
+ }
16
+ const required = new Set(input.requiredScopes ?? []);
17
+ if (required.size === 0)
18
+ return installation;
19
+ const grants = await db
20
+ .select({ scope: appGrants.scope })
21
+ .from(appGrants)
22
+ .where(and(eq(appGrants.installationId, installation.id), eq(appGrants.status, "granted")));
23
+ for (const grant of grants)
24
+ required.delete(grant.scope);
25
+ if (required.size > 0) {
26
+ throw new ApiHttpError("App installation is missing required scopes", {
27
+ status: 403,
28
+ code: "app_installation_scope_missing",
29
+ details: { scopes: [...required].sort() },
30
+ });
31
+ }
32
+ return installation;
33
+ }
@@ -0,0 +1,15 @@
1
+ import type { AccessCatalog } from "@voyant-travel/types/api-keys";
2
+ import type { AppRelease } from "./schema.js";
3
+ export interface ConsentComputationInput {
4
+ release: AppRelease;
5
+ accessCatalog: AccessCatalog;
6
+ operatorGrantedScopes: readonly string[];
7
+ grantedOptionalScopes?: readonly string[];
8
+ }
9
+ export interface ComputedConsent {
10
+ requiredScopes: string[];
11
+ optionalScopes: string[];
12
+ grantedScopes: string[];
13
+ deniedOptionalScopes: string[];
14
+ }
15
+ export declare function computeAppConsent(input: ConsentComputationInput): ComputedConsent;
@@ -0,0 +1,50 @@
1
+ import { ApiHttpError } from "@voyant-travel/hono";
2
+ export function computeAppConsent(input) {
3
+ const normalized = parseReleaseScopes(input.release.normalizedRecord);
4
+ const remoteSafe = remoteSafeScopes(input.accessCatalog);
5
+ const operatorGranted = new Set(input.operatorGrantedScopes);
6
+ const optionalGrantRequest = new Set(input.grantedOptionalScopes ?? []);
7
+ const requiredScopes = normalized.requestedScopes.filter((scope) => canGrantScope(scope, remoteSafe, operatorGranted));
8
+ const missingRequired = normalized.requestedScopes.filter((scope) => !requiredScopes.includes(scope));
9
+ if (missingRequired.length > 0) {
10
+ throw new ApiHttpError("Required app scopes are not grantable for remote apps", {
11
+ status: 403,
12
+ code: "app_required_scope_not_grantable",
13
+ details: { scopes: missingRequired },
14
+ });
15
+ }
16
+ const optionalScopes = normalized.optionalScopes.filter((scope) => canGrantScope(scope, remoteSafe, operatorGranted));
17
+ const grantedOptional = optionalScopes.filter((scope) => optionalGrantRequest.has(scope));
18
+ const deniedOptionalScopes = normalized.optionalScopes.filter((scope) => !grantedOptional.includes(scope));
19
+ const grantedScopes = Array.from(new Set([...requiredScopes, ...grantedOptional])).sort();
20
+ return {
21
+ requiredScopes: requiredScopes.sort(),
22
+ optionalScopes: optionalScopes.sort(),
23
+ grantedScopes,
24
+ deniedOptionalScopes: deniedOptionalScopes.sort(),
25
+ };
26
+ }
27
+ function canGrantScope(scope, remoteSafe, operatorGranted) {
28
+ return remoteSafe.has(scope) && operatorGranted.has(scope);
29
+ }
30
+ function remoteSafeScopes(catalog) {
31
+ const catalogScopes = new Set(catalog.resources.flatMap((resource) => resource.actions.map((action) => `${resource.resource}:${action.action}`)));
32
+ const presetScopes = catalog.presets
33
+ .filter((preset) => preset.kind === "api-token-grant" && Boolean(preset.audience))
34
+ .flatMap((preset) => preset.grants);
35
+ return new Set(presetScopes.filter((scope) => catalogScopes.has(scope)));
36
+ }
37
+ function parseReleaseScopes(value) {
38
+ if (!value || typeof value !== "object")
39
+ return { requestedScopes: [], optionalScopes: [] };
40
+ const record = value;
41
+ return {
42
+ requestedScopes: stringArray(record.requestedScopes),
43
+ optionalScopes: stringArray(record.optionalScopes),
44
+ };
45
+ }
46
+ function stringArray(value) {
47
+ return Array.isArray(value)
48
+ ? Array.from(new Set(value.filter((item) => typeof item === "string"))).sort()
49
+ : [];
50
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { computeAppConsent } from "./consent.js";
3
+ const catalog = {
4
+ resources: [
5
+ {
6
+ id: "bookings",
7
+ unitId: "bookings",
8
+ resource: "bookings",
9
+ label: "Bookings",
10
+ description: "Bookings",
11
+ wildcard: "allow",
12
+ actions: [{ action: "read", label: "Read", description: "Read" }],
13
+ },
14
+ {
15
+ id: "invoices",
16
+ unitId: "finance",
17
+ resource: "invoices",
18
+ label: "Invoices",
19
+ description: "Invoices",
20
+ wildcard: "allow",
21
+ actions: [{ action: "read", label: "Read", description: "Read" }],
22
+ },
23
+ ],
24
+ presets: [
25
+ {
26
+ id: "remote-safe",
27
+ kind: "api-token-grant",
28
+ audience: "staff",
29
+ label: "Remote safe",
30
+ description: "Remote app grants",
31
+ grants: ["bookings:read"],
32
+ },
33
+ ],
34
+ };
35
+ function release(requestedScopes, optionalScopes) {
36
+ return {
37
+ id: "release_1",
38
+ appId: "app_1",
39
+ releaseVersion: "1.0.0",
40
+ manifestSchemaVersion: "voyant.app-manifest.v1",
41
+ manifestDigest: "digest",
42
+ manifestSnapshot: {},
43
+ normalizedRecord: {
44
+ schemaVersion: "voyant.app-release.normalized.v1",
45
+ releaseVersion: "1.0.0",
46
+ digest: "digest",
47
+ requestedScopes,
48
+ optionalScopes,
49
+ adminPages: [],
50
+ slotExtensions: [],
51
+ webhooks: [],
52
+ customFields: [],
53
+ },
54
+ apiCompatibility: { min: "2026-01-01", max: "2026-12-31" },
55
+ defaultLocale: "en-US",
56
+ supportedLocales: ["en-US"],
57
+ state: "available",
58
+ createdBy: "user_1",
59
+ createdAt: new Date(),
60
+ };
61
+ }
62
+ describe("app OAuth consent computation", () => {
63
+ it("grants only scopes present in the catalog, remote-safe, and operator-granted", () => {
64
+ const consent = computeAppConsent({
65
+ release: release(["bookings:read"], ["invoices:read"]),
66
+ accessCatalog: catalog,
67
+ operatorGrantedScopes: ["bookings:read", "invoices:read"],
68
+ grantedOptionalScopes: ["invoices:read"],
69
+ });
70
+ expect(consent.grantedScopes).toEqual(["bookings:read"]);
71
+ expect(consent.deniedOptionalScopes).toEqual(["invoices:read"]);
72
+ });
73
+ it("rejects required scopes that are absent, unsafe, or not operator-granted", () => {
74
+ expect(() => computeAppConsent({
75
+ release: release(["invoices:read"], []),
76
+ accessCatalog: catalog,
77
+ operatorGrantedScopes: ["invoices:read"],
78
+ })).toThrow("Required app scopes are not grantable");
79
+ });
80
+ });
@@ -176,9 +176,88 @@ export declare const appWebhookReplaySchema: z.ZodObject<{
176
176
  signingKeyId: z.ZodString;
177
177
  signingSecret: z.ZodString;
178
178
  }, z.core.$strict>;
179
+ export declare const appOAuthAuthorizeQuerySchema: z.ZodObject<{
180
+ response_type: z.ZodLiteral<"code">;
181
+ client_id: z.ZodString;
182
+ release_id: z.ZodString;
183
+ redirect_uri: z.ZodString;
184
+ state: z.ZodString;
185
+ code_challenge: z.ZodString;
186
+ code_challenge_method: z.ZodLiteral<"S256">;
187
+ actor_id: z.ZodString;
188
+ operator_scopes: z.ZodDefault<z.ZodString>;
189
+ optional_scopes: z.ZodDefault<z.ZodString>;
190
+ }, z.core.$strict>;
191
+ export declare const appOAuthTokenSchema: z.ZodPipe<z.ZodDiscriminatedUnion<[z.ZodObject<{
192
+ grant_type: z.ZodLiteral<"authorization_code">;
193
+ code: z.ZodString;
194
+ redirect_uri: z.ZodString;
195
+ code_verifier: z.ZodString;
196
+ client_id: z.ZodString;
197
+ client_secret: z.ZodOptional<z.ZodString>;
198
+ }, z.core.$strip>, z.ZodObject<{
199
+ grant_type: z.ZodLiteral<"refresh_token">;
200
+ refresh_token: z.ZodString;
201
+ client_id: z.ZodString;
202
+ client_secret: z.ZodOptional<z.ZodString>;
203
+ }, z.core.$strip>, z.ZodObject<{
204
+ grant_type: z.ZodLiteral<"urn:voyant:params:oauth:grant-type:actor-token-exchange">;
205
+ installation_id: z.ZodString;
206
+ viewer_id: z.ZodString;
207
+ viewer_scopes: z.ZodDefault<z.ZodArray<z.ZodString>>;
208
+ contextual_scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
209
+ client_id: z.ZodString;
210
+ client_secret: z.ZodOptional<z.ZodString>;
211
+ }, z.core.$strip>], "grant_type">, z.ZodTransform<{
212
+ client_secret: string | undefined;
213
+ grant_type: "authorization_code";
214
+ code: string;
215
+ redirect_uri: string;
216
+ code_verifier: string;
217
+ client_id: string;
218
+ } | {
219
+ client_secret: string | undefined;
220
+ grant_type: "refresh_token";
221
+ refresh_token: string;
222
+ client_id: string;
223
+ } | {
224
+ client_secret: string | undefined;
225
+ grant_type: "urn:voyant:params:oauth:grant-type:actor-token-exchange";
226
+ installation_id: string;
227
+ viewer_id: string;
228
+ viewer_scopes: string[];
229
+ client_id: string;
230
+ contextual_scopes?: string[] | undefined;
231
+ }, {
232
+ grant_type: "authorization_code";
233
+ code: string;
234
+ redirect_uri: string;
235
+ code_verifier: string;
236
+ client_id: string;
237
+ client_secret?: string | undefined;
238
+ } | {
239
+ grant_type: "refresh_token";
240
+ refresh_token: string;
241
+ client_id: string;
242
+ client_secret?: string | undefined;
243
+ } | {
244
+ grant_type: "urn:voyant:params:oauth:grant-type:actor-token-exchange";
245
+ installation_id: string;
246
+ viewer_id: string;
247
+ viewer_scopes: string[];
248
+ client_id: string;
249
+ contextual_scopes?: string[] | undefined;
250
+ client_secret?: string | undefined;
251
+ }>>;
252
+ export declare const appCredentialRevocationSchema: z.ZodObject<{
253
+ installationId: z.ZodString;
254
+ actorId: z.ZodString;
255
+ }, z.core.$strict>;
179
256
  export type AppManifest = z.infer<typeof appManifestSchema>;
180
257
  export type CreateCustomAppRegistrationInput = z.infer<typeof createCustomAppRegistrationSchema>;
181
258
  export type ReleaseManifestUploadInput = z.infer<typeof releaseManifestUploadSchema>;
182
259
  export type ReleaseManifestFetchInput = z.infer<typeof releaseManifestFetchSchema>;
183
260
  export type AppListQuery = z.infer<typeof appListQuerySchema>;
184
261
  export type AppWebhookReplayInput = z.infer<typeof appWebhookReplaySchema>;
262
+ export type AppOAuthAuthorizeQuery = z.infer<typeof appOAuthAuthorizeQuerySchema>;
263
+ export type AppOAuthTokenInput = z.infer<typeof appOAuthTokenSchema>;
package/dist/contracts.js CHANGED
@@ -221,3 +221,53 @@ export const appWebhookReplaySchema = z
221
221
  signingSecret: z.string().min(32),
222
222
  })
223
223
  .strict();
224
+ export const appOAuthAuthorizeQuerySchema = z
225
+ .object({
226
+ response_type: z.literal("code"),
227
+ client_id: z.string().trim().min(1),
228
+ release_id: z.string().trim().min(1),
229
+ redirect_uri: z.string().url(),
230
+ state: z.string().trim().min(1),
231
+ code_challenge: z.string().trim().min(1),
232
+ code_challenge_method: z.literal("S256"),
233
+ actor_id: z.string().trim().min(1),
234
+ operator_scopes: z.string().trim().default(""),
235
+ optional_scopes: z.string().trim().default(""),
236
+ })
237
+ .strict();
238
+ export const appOAuthTokenSchema = z
239
+ .discriminatedUnion("grant_type", [
240
+ z.object({
241
+ grant_type: z.literal("authorization_code"),
242
+ code: z.string().trim().min(1),
243
+ redirect_uri: z.string().url(),
244
+ code_verifier: z.string().trim().min(43).max(128),
245
+ client_id: z.string().trim().min(1),
246
+ client_secret: z.string().trim().optional(),
247
+ }),
248
+ z.object({
249
+ grant_type: z.literal("refresh_token"),
250
+ refresh_token: z.string().trim().min(1),
251
+ client_id: z.string().trim().min(1),
252
+ client_secret: z.string().trim().optional(),
253
+ }),
254
+ z.object({
255
+ grant_type: z.literal("urn:voyant:params:oauth:grant-type:actor-token-exchange"),
256
+ installation_id: z.string().trim().min(1),
257
+ viewer_id: z.string().trim().min(1),
258
+ viewer_scopes: z.array(scopeSchema).default([]),
259
+ contextual_scopes: z.array(scopeSchema).optional(),
260
+ client_id: z.string().trim().min(1),
261
+ client_secret: z.string().trim().optional(),
262
+ }),
263
+ ])
264
+ .transform((input) => ({
265
+ ...input,
266
+ client_secret: input.client_secret?.trim() || undefined,
267
+ }));
268
+ export const appCredentialRevocationSchema = z
269
+ .object({
270
+ installationId: z.string().trim().min(1),
271
+ actorId: z.string().trim().min(1),
272
+ })
273
+ .strict();
package/dist/index.d.ts CHANGED
@@ -1,9 +1,13 @@
1
+ export * from "./access-boundary.js";
1
2
  export * from "./compiler.js";
3
+ export * from "./consent.js";
2
4
  export * from "./contracts.js";
3
5
  export * from "./ingestion.js";
4
6
  export * from "./installation-service.js";
7
+ export * from "./oauth-crypto.js";
8
+ export * from "./oauth-service.js";
5
9
  export { createAppsAdminRoutes } from "./routes.js";
6
- export { appAccessCredentialStatusEnum, appAccessCredentials, appAuditEventKindEnum, appAuditEvents, appCredentialKindEnum, appCredentials, appDistributionEnum, appExtensionInstallations, appGrantStatusEnum, appGrants, appInstallationRegistrationStatusEnum, appInstallationSettings, appInstallationStatusEnum, appInstallations, appInstallationUpdatePolicyEnum, appLifecycleStateEnum, appRedirectUris, appReleaseArtifactStateEnum, appReleaseArtifacts, appReleaseLocalizations, appReleaseStateEnum, appReleases, appSecretReferences, apps, appWebhookSubscriptionStatusEnum, appWebhookSubscriptions, } from "./schema.js";
10
+ export { appAccessCredentialStatusEnum, appAccessCredentials, appAccessTokenModeEnum, appAuditEventKindEnum, appAuditEvents, appCredentialKindEnum, appCredentials, appDistributionEnum, appExtensionInstallations, appGrantStatusEnum, appGrants, appInstallationRegistrationStatusEnum, appInstallationSettings, appInstallationStatusEnum, appInstallations, appInstallationUpdatePolicyEnum, appLifecycleStateEnum, appOAuthAuthorizationCodes, appOAuthRefreshTokens, appRedirectUris, appReleaseArtifactStateEnum, appReleaseArtifacts, appReleaseLocalizations, appReleaseStateEnum, appReleases, appSecretReferences, apps, appWebhookSubscriptionStatusEnum, appWebhookSubscriptions, } from "./schema.js";
7
11
  export { createAppsService } from "./service.js";
8
12
  export type { AppWebhookDeliveryOptions } from "./webhook-delivery.js";
9
13
  export { createAppWebhookDeliveryStore, createAppWebhookEventQueue, enqueueAppWebhookEvent, listAppWebhookHealth, replayAppWebhookDelivery, } from "./webhook-delivery.js";
package/dist/index.js CHANGED
@@ -1,8 +1,12 @@
1
+ export * from "./access-boundary.js";
1
2
  export * from "./compiler.js";
3
+ export * from "./consent.js";
2
4
  export * from "./contracts.js";
3
5
  export * from "./ingestion.js";
4
6
  export * from "./installation-service.js";
7
+ export * from "./oauth-crypto.js";
8
+ export * from "./oauth-service.js";
5
9
  export { createAppsAdminRoutes } from "./routes.js";
6
- export { appAccessCredentialStatusEnum, appAccessCredentials, appAuditEventKindEnum, appAuditEvents, appCredentialKindEnum, appCredentials, appDistributionEnum, appExtensionInstallations, appGrantStatusEnum, appGrants, appInstallationRegistrationStatusEnum, appInstallationSettings, appInstallationStatusEnum, appInstallations, appInstallationUpdatePolicyEnum, appLifecycleStateEnum, appRedirectUris, appReleaseArtifactStateEnum, appReleaseArtifacts, appReleaseLocalizations, appReleaseStateEnum, appReleases, appSecretReferences, apps, appWebhookSubscriptionStatusEnum, appWebhookSubscriptions, } from "./schema.js";
10
+ export { appAccessCredentialStatusEnum, appAccessCredentials, appAccessTokenModeEnum, appAuditEventKindEnum, appAuditEvents, appCredentialKindEnum, appCredentials, appDistributionEnum, appExtensionInstallations, appGrantStatusEnum, appGrants, appInstallationRegistrationStatusEnum, appInstallationSettings, appInstallationStatusEnum, appInstallations, appInstallationUpdatePolicyEnum, appLifecycleStateEnum, appOAuthAuthorizationCodes, appOAuthRefreshTokens, appRedirectUris, appReleaseArtifactStateEnum, appReleaseArtifacts, appReleaseLocalizations, appReleaseStateEnum, appReleases, appSecretReferences, apps, appWebhookSubscriptionStatusEnum, appWebhookSubscriptions, } from "./schema.js";
7
11
  export { createAppsService } from "./service.js";
8
12
  export { createAppWebhookDeliveryStore, createAppWebhookEventQueue, enqueueAppWebhookEvent, listAppWebhookHealth, replayAppWebhookDelivery, } from "./webhook-delivery.js";
@@ -14,5 +14,5 @@ export declare function deactivateRuntimeState(db: PostgresJsDatabase, installat
14
14
  export declare function deactivateResolvedRegistrations(db: PostgresJsDatabase, installation: AppInstallation): Promise<void>;
15
15
  export declare function markAppDefinitionsInactive(db: PostgresJsDatabase, installation: AppInstallation, actorId: string): Promise<void>;
16
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" | "credential" | "reconciliation" | "purge", action: string, details: Record<string, unknown>): Promise<void>;
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
18
  export {};
@@ -1,7 +1,7 @@
1
1
  import { createAppCustomFieldDefinitionOwner, customFieldDefinitions, } from "@voyant-travel/custom-fields";
2
2
  import { ApiHttpError } from "@voyant-travel/hono";
3
3
  import { and, eq, sql } from "drizzle-orm";
4
- import { appAccessCredentials, appAuditEvents, appExtensionInstallations, appGrants, appWebhookSubscriptions, } from "./schema.js";
4
+ import { appAccessCredentials, appAuditEvents, appExtensionInstallations, appGrants, appOAuthRefreshTokens, appWebhookSubscriptions, } from "./schema.js";
5
5
  export async function reconcileRelease(db, installation, release, actorId, action, options) {
6
6
  const normalized = parseNormalizedRelease(release.normalizedRecord);
7
7
  await reconcileCustomFields(db, installation, release, normalized, actorId, options);
@@ -78,6 +78,10 @@ export async function deactivateRuntimeState(db, installation) {
78
78
  .update(appAccessCredentials)
79
79
  .set({ status: "inactive", deactivatedAt: now })
80
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));
81
85
  await deactivateResolvedRegistrations(db, installation);
82
86
  }
83
87
  export async function deactivateResolvedRegistrations(db, installation) {
@@ -58,6 +58,7 @@ export declare function createAppInstallationService(options?: AppInstallationSe
58
58
  status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
59
59
  namespace: string;
60
60
  installedBy: string;
61
+ credentialGeneration: number;
61
62
  updatePolicy: "manual" | "compatible" | "patch" | "pinned";
62
63
  lastCompatibleReleaseCheckAt: Date | null;
63
64
  pendingReleaseId: string | null;
@@ -76,15 +77,16 @@ export declare function createAppInstallationService(options?: AppInstallationSe
76
77
  outcome: "unchanged";
77
78
  } | {
78
79
  installation: {
79
- status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
80
80
  id: string;
81
81
  createdAt: Date;
82
82
  updatedAt: Date;
83
83
  appId: string;
84
84
  releaseId: string;
85
85
  deploymentId: string;
86
+ status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
86
87
  namespace: string;
87
88
  installedBy: string;
89
+ credentialGeneration: number;
88
90
  updatePolicy: "manual" | "compatible" | "patch" | "pinned";
89
91
  lastCompatibleReleaseCheckAt: Date | null;
90
92
  pendingReleaseId: string | null;
@@ -109,6 +111,7 @@ export declare function createAppInstallationService(options?: AppInstallationSe
109
111
  status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
110
112
  namespace: string;
111
113
  installedBy: string;
114
+ credentialGeneration: number;
112
115
  updatePolicy: "manual" | "compatible" | "patch" | "pinned";
113
116
  lastCompatibleReleaseCheckAt: Date | null;
114
117
  pendingReleaseId: string | null;
@@ -135,6 +138,7 @@ export declare function createAppInstallationService(options?: AppInstallationSe
135
138
  status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
136
139
  namespace: string;
137
140
  installedBy: string;
141
+ credentialGeneration: number;
138
142
  updatePolicy: "manual" | "compatible" | "patch" | "pinned";
139
143
  lastCompatibleReleaseCheckAt: Date | null;
140
144
  pendingReleaseId: string | null;
@@ -162,6 +166,7 @@ export declare function createAppInstallationService(options?: AppInstallationSe
162
166
  status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
163
167
  namespace: string;
164
168
  installedBy: string;
169
+ credentialGeneration: number;
165
170
  updatePolicy: "manual" | "compatible" | "patch" | "pinned";
166
171
  lastCompatibleReleaseCheckAt: Date | null;
167
172
  pendingReleaseId: string | null;
@@ -187,6 +192,7 @@ export declare function createAppInstallationService(options?: AppInstallationSe
187
192
  status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
188
193
  namespace: string;
189
194
  installedBy: string;
195
+ credentialGeneration: number;
190
196
  updatePolicy: "manual" | "compatible" | "patch" | "pinned";
191
197
  lastCompatibleReleaseCheckAt: Date | null;
192
198
  pendingReleaseId: string | null;
@@ -213,6 +219,7 @@ export declare function createAppInstallationService(options?: AppInstallationSe
213
219
  status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
214
220
  namespace: string;
215
221
  installedBy: string;
222
+ credentialGeneration: number;
216
223
  updatePolicy: "manual" | "compatible" | "patch" | "pinned";
217
224
  lastCompatibleReleaseCheckAt: Date | null;
218
225
  pendingReleaseId: string | null;
@@ -238,6 +245,7 @@ export declare function createAppInstallationService(options?: AppInstallationSe
238
245
  status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
239
246
  namespace: string;
240
247
  installedBy: string;
248
+ credentialGeneration: number;
241
249
  updatePolicy: "manual" | "compatible" | "patch" | "pinned";
242
250
  lastCompatibleReleaseCheckAt: Date | null;
243
251
  pendingReleaseId: string | null;
@@ -264,6 +272,7 @@ export declare function createAppInstallationService(options?: AppInstallationSe
264
272
  status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
265
273
  namespace: string;
266
274
  installedBy: string;
275
+ credentialGeneration: number;
267
276
  updatePolicy: "manual" | "compatible" | "patch" | "pinned";
268
277
  lastCompatibleReleaseCheckAt: Date | null;
269
278
  pendingReleaseId: string | null;
@@ -289,6 +298,7 @@ export declare function createAppInstallationService(options?: AppInstallationSe
289
298
  status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
290
299
  namespace: string;
291
300
  installedBy: string;
301
+ credentialGeneration: number;
292
302
  updatePolicy: "manual" | "compatible" | "patch" | "pinned";
293
303
  lastCompatibleReleaseCheckAt: Date | null;
294
304
  pendingReleaseId: string | null;
@@ -315,6 +325,7 @@ export declare function createAppInstallationService(options?: AppInstallationSe
315
325
  status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
316
326
  namespace: string;
317
327
  installedBy: string;
328
+ credentialGeneration: number;
318
329
  updatePolicy: "manual" | "compatible" | "patch" | "pinned";
319
330
  lastCompatibleReleaseCheckAt: Date | null;
320
331
  pendingReleaseId: string | null;
@@ -1,5 +1,5 @@
1
1
  import { ApiHttpError } from "@voyant-travel/hono";
2
- import { and, eq } from "drizzle-orm";
2
+ import { and, eq, sql } from "drizzle-orm";
3
3
  import { audit, countInstallationRows, deactivateResolvedRegistrations, deactivateRuntimeState, markAppDefinitionsInactive, newlyRequiredScopes, reconcileGrants, reconcileRelease, rotateCredential, } from "./installation-reconciliation.js";
4
4
  import { canInstallOver, invalidTransition, planLifecycleTransition } from "./installation-state.js";
5
5
  import { appAccessCredentials, appExtensionInstallations, appGrants, appInstallations, appReleases, apps, appWebhookSubscriptions, } from "./schema.js";
@@ -295,14 +295,29 @@ function lifecyclePatch(to, now) {
295
295
  const base = { status: to, updatedAt: now };
296
296
  if (to === "active")
297
297
  return { ...base, activatedAt: now };
298
- if (to === "paused")
299
- return { ...base, pausedAt: now };
298
+ if (to === "paused") {
299
+ return {
300
+ ...base,
301
+ pausedAt: now,
302
+ credentialGeneration: sql `${appInstallations.credentialGeneration} + 1`,
303
+ };
304
+ }
300
305
  if (to === "degraded")
301
306
  return { ...base, degradedAt: now };
302
- if (to === "revoked")
303
- return { ...base, revokedAt: now };
304
- if (to === "uninstalled")
305
- return { ...base, uninstalledAt: now };
307
+ if (to === "revoked") {
308
+ return {
309
+ ...base,
310
+ revokedAt: now,
311
+ credentialGeneration: sql `${appInstallations.credentialGeneration} + 1`,
312
+ };
313
+ }
314
+ if (to === "uninstalled") {
315
+ return {
316
+ ...base,
317
+ uninstalledAt: now,
318
+ credentialGeneration: sql `${appInstallations.credentialGeneration} + 1`,
319
+ };
320
+ }
306
321
  return base;
307
322
  }
308
323
  function writeFailed() {
@@ -0,0 +1,8 @@
1
+ export declare const APP_ACCESS_TOKEN_PREFIX = "vapp_";
2
+ export declare const APP_REFRESH_TOKEN_PREFIX = "vappr_";
3
+ export declare const APP_AUTH_CODE_PREFIX = "vappc_";
4
+ export declare function randomToken(prefix: string, bytes?: number): string;
5
+ export declare function sha256Hex(value: string): string;
6
+ export declare function sha256Base64Url(value: string): string;
7
+ export declare function constantTimeEqual(left: string, right: string): boolean;
8
+ export declare function verifyPkceS256(verifier: string, challenge: string): boolean;
@@ -0,0 +1,23 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ export const APP_ACCESS_TOKEN_PREFIX = "vapp_";
3
+ export const APP_REFRESH_TOKEN_PREFIX = "vappr_";
4
+ export const APP_AUTH_CODE_PREFIX = "vappc_";
5
+ export function randomToken(prefix, bytes = 32) {
6
+ return `${prefix}${randomBytes(bytes).toString("base64url")}`;
7
+ }
8
+ export function sha256Hex(value) {
9
+ return createHash("sha256").update(value).digest("hex");
10
+ }
11
+ export function sha256Base64Url(value) {
12
+ return createHash("sha256").update(value).digest("base64url");
13
+ }
14
+ export function constantTimeEqual(left, right) {
15
+ const leftHash = Buffer.from(sha256Hex(left), "hex");
16
+ const rightHash = Buffer.from(sha256Hex(right), "hex");
17
+ return timingSafeEqual(leftHash, rightHash);
18
+ }
19
+ export function verifyPkceS256(verifier, challenge) {
20
+ if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier))
21
+ return false;
22
+ return constantTimeEqual(sha256Base64Url(verifier), challenge);
23
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { sha256Base64Url, verifyPkceS256 } from "./oauth-crypto.js";
3
+ describe("app OAuth PKCE", () => {
4
+ it("accepts only S256 challenges derived from the verifier", () => {
5
+ const verifier = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ_0123456789-1234567890";
6
+ const challenge = sha256Base64Url(verifier);
7
+ expect(verifyPkceS256(verifier, challenge)).toBe(true);
8
+ expect(verifyPkceS256(`${verifier}x`, challenge)).toBe(false);
9
+ expect(verifyPkceS256("too-short", challenge)).toBe(false);
10
+ });
11
+ });