@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.
- package/dist/access-boundary.d.ts +29 -0
- package/dist/access-boundary.js +33 -0
- package/dist/consent.d.ts +15 -0
- package/dist/consent.js +50 -0
- package/dist/consent.test.d.ts +1 -0
- package/dist/consent.test.js +80 -0
- package/dist/contracts.d.ts +79 -0
- package/dist/contracts.js +50 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/installation-reconciliation.d.ts +1 -1
- package/dist/installation-reconciliation.js +5 -1
- package/dist/installation-service.d.ts +12 -1
- package/dist/installation-service.js +22 -7
- package/dist/oauth-crypto.d.ts +8 -0
- package/dist/oauth-crypto.js +23 -0
- package/dist/oauth-crypto.test.d.ts +1 -0
- package/dist/oauth-crypto.test.js +11 -0
- package/dist/oauth-service.d.ts +66 -0
- package/dist/oauth-service.js +398 -0
- package/dist/oauth-service.test.d.ts +1 -0
- package/dist/oauth-service.test.js +60 -0
- package/dist/routes.d.ts +8 -1
- package/dist/routes.js +70 -1
- package/dist/schema.d.ts +536 -4
- package/dist/schema.js +52 -1
- package/dist/service.d.ts +16 -16
- package/migrations/20260717143000_app_oauth_authorization.sql +47 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +24 -3
|
@@ -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;
|
package/dist/consent.js
ADDED
|
@@ -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
|
+
});
|
package/dist/contracts.d.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
304
|
-
|
|
305
|
-
|
|
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
|
+
});
|