@voyant-travel/apps 0.5.0 → 0.6.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/api-runtime.d.ts +3 -0
- package/dist/app-api-routes.js +1 -1
- package/dist/app-api-service.js +1 -1
- package/dist/contracts.d.ts +43 -0
- package/dist/contracts.js +41 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/installation-read-model.d.ts +64 -0
- package/dist/installation-read-model.js +148 -0
- package/dist/installation-routes.test.d.ts +1 -0
- package/dist/installation-routes.test.js +161 -0
- package/dist/routes.d.ts +16 -0
- package/dist/routes.js +93 -1
- package/dist/voyant.js +52 -2
- package/package.json +1 -1
package/dist/api-runtime.d.ts
CHANGED
|
@@ -3,6 +3,9 @@ export declare const createAppsApiModule: import("@voyant-travel/core/project").
|
|
|
3
3
|
name: string;
|
|
4
4
|
};
|
|
5
5
|
adminRoutes: import("hono").Hono<{
|
|
6
|
+
Bindings: {
|
|
7
|
+
VOYANT_CLOUD_DEPLOYMENT_ID?: string;
|
|
8
|
+
};
|
|
6
9
|
Variables: {
|
|
7
10
|
db: import("drizzle-orm/postgres-js").PostgresJsDatabase;
|
|
8
11
|
};
|
package/dist/app-api-routes.js
CHANGED
|
@@ -72,7 +72,7 @@ export function createAppsAppApiRoutes(options = {}) {
|
|
|
72
72
|
});
|
|
73
73
|
routes.get("/webhooks", (c) => run(c, service.listWebhookHealth(c.get("db"), appContext(c)), options.deadlineMs));
|
|
74
74
|
routes.post("/webhooks/replay", async (c) => {
|
|
75
|
-
await service.requireAccess(c.get("db"), appContext(c), ["webhooks:replay"]);
|
|
75
|
+
await service.requireAccess(c.get("db"), appContext(c), ["app-webhooks:replay"]);
|
|
76
76
|
const body = await parseJsonBody(c, appApiWebhookReplaySchema);
|
|
77
77
|
return run(c, replayAppWebhookDelivery(c.get("db"), {
|
|
78
78
|
deliveryId: body.deliveryId,
|
package/dist/app-api-service.js
CHANGED
|
@@ -99,7 +99,7 @@ export function createAppApiService(options = {}) {
|
|
|
99
99
|
};
|
|
100
100
|
}
|
|
101
101
|
async function listWebhookHealth(db, context) {
|
|
102
|
-
await requireAccess(db, context, ["webhooks:read"]);
|
|
102
|
+
await requireAccess(db, context, ["app-webhooks:read"]);
|
|
103
103
|
const data = await db
|
|
104
104
|
.select()
|
|
105
105
|
.from(appWebhookSubscriptions)
|
package/dist/contracts.d.ts
CHANGED
|
@@ -170,6 +170,44 @@ export declare const appListQuerySchema: z.ZodObject<{
|
|
|
170
170
|
limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
171
171
|
offset: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
172
172
|
}, z.core.$strip>;
|
|
173
|
+
export declare const appInstallationListQuerySchema: z.ZodObject<{
|
|
174
|
+
appId: z.ZodOptional<z.ZodString>;
|
|
175
|
+
status: z.ZodOptional<z.ZodEnum<{
|
|
176
|
+
active: "active";
|
|
177
|
+
pending: "pending";
|
|
178
|
+
authorizing: "authorizing";
|
|
179
|
+
paused: "paused";
|
|
180
|
+
degraded: "degraded";
|
|
181
|
+
revoked: "revoked";
|
|
182
|
+
uninstalled: "uninstalled";
|
|
183
|
+
}>>;
|
|
184
|
+
deploymentId: z.ZodOptional<z.ZodString>;
|
|
185
|
+
limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
186
|
+
offset: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
187
|
+
}, z.core.$strip>;
|
|
188
|
+
export declare const appInstallationAuditQuerySchema: z.ZodObject<{
|
|
189
|
+
limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
190
|
+
}, z.core.$strip>;
|
|
191
|
+
export declare const installAppSchema: z.ZodObject<{
|
|
192
|
+
appId: z.ZodString;
|
|
193
|
+
releaseId: z.ZodString;
|
|
194
|
+
actorId: z.ZodString;
|
|
195
|
+
grantedOptionalScopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
196
|
+
updatePolicy: z.ZodOptional<z.ZodEnum<{
|
|
197
|
+
manual: "manual";
|
|
198
|
+
compatible: "compatible";
|
|
199
|
+
patch: "patch";
|
|
200
|
+
pinned: "pinned";
|
|
201
|
+
}>>;
|
|
202
|
+
deploymentId: z.ZodOptional<z.ZodString>;
|
|
203
|
+
}, z.core.$strict>;
|
|
204
|
+
export declare const lifecycleActionBodySchema: z.ZodObject<{
|
|
205
|
+
actorId: z.ZodString;
|
|
206
|
+
}, z.core.$strict>;
|
|
207
|
+
export declare const activateInstallationBodySchema: z.ZodObject<{
|
|
208
|
+
releaseId: z.ZodString;
|
|
209
|
+
actorId: z.ZodString;
|
|
210
|
+
}, z.core.$strict>;
|
|
173
211
|
export declare const appWebhookReplaySchema: z.ZodObject<{
|
|
174
212
|
deliveryId: z.ZodString;
|
|
175
213
|
actorId: z.ZodString;
|
|
@@ -284,6 +322,11 @@ export type CreateCustomAppRegistrationInput = z.infer<typeof createCustomAppReg
|
|
|
284
322
|
export type ReleaseManifestUploadInput = z.infer<typeof releaseManifestUploadSchema>;
|
|
285
323
|
export type ReleaseManifestFetchInput = z.infer<typeof releaseManifestFetchSchema>;
|
|
286
324
|
export type AppListQuery = z.infer<typeof appListQuerySchema>;
|
|
325
|
+
export type AppInstallationListQuery = z.infer<typeof appInstallationListQuerySchema>;
|
|
326
|
+
export type AppInstallationAuditQuery = z.infer<typeof appInstallationAuditQuerySchema>;
|
|
327
|
+
export type InstallAppRequest = z.infer<typeof installAppSchema>;
|
|
328
|
+
export type LifecycleActionBody = z.infer<typeof lifecycleActionBodySchema>;
|
|
329
|
+
export type ActivateInstallationBody = z.infer<typeof activateInstallationBodySchema>;
|
|
287
330
|
export type AppWebhookReplayInput = z.infer<typeof appWebhookReplaySchema>;
|
|
288
331
|
export type AppOAuthAuthorizeQuery = z.infer<typeof appOAuthAuthorizeQuerySchema>;
|
|
289
332
|
export type AppOAuthTokenInput = z.infer<typeof appOAuthTokenSchema>;
|
package/dist/contracts.js
CHANGED
|
@@ -213,6 +213,47 @@ export const appListQuerySchema = z.object({
|
|
|
213
213
|
limit: z.coerce.number().int().positive().max(100).default(25),
|
|
214
214
|
offset: z.coerce.number().int().nonnegative().default(0),
|
|
215
215
|
});
|
|
216
|
+
const appInstallationStatusValues = [
|
|
217
|
+
"pending",
|
|
218
|
+
"authorizing",
|
|
219
|
+
"active",
|
|
220
|
+
"paused",
|
|
221
|
+
"degraded",
|
|
222
|
+
"revoked",
|
|
223
|
+
"uninstalled",
|
|
224
|
+
];
|
|
225
|
+
const appInstallationUpdatePolicyValues = ["manual", "compatible", "patch", "pinned"];
|
|
226
|
+
export const appInstallationListQuerySchema = z.object({
|
|
227
|
+
appId: z.string().trim().min(1).optional(),
|
|
228
|
+
status: z.enum(appInstallationStatusValues).optional(),
|
|
229
|
+
deploymentId: z.string().trim().min(1).optional(),
|
|
230
|
+
limit: z.coerce.number().int().positive().max(100).default(25),
|
|
231
|
+
offset: z.coerce.number().int().nonnegative().default(0),
|
|
232
|
+
});
|
|
233
|
+
export const appInstallationAuditQuerySchema = z.object({
|
|
234
|
+
limit: z.coerce.number().int().positive().max(200).default(50),
|
|
235
|
+
});
|
|
236
|
+
export const installAppSchema = z
|
|
237
|
+
.object({
|
|
238
|
+
appId: z.string().trim().min(1),
|
|
239
|
+
releaseId: z.string().trim().min(1),
|
|
240
|
+
actorId: z.string().trim().min(1).max(160),
|
|
241
|
+
grantedOptionalScopes: z.array(scopeSchema).optional(),
|
|
242
|
+
updatePolicy: z.enum(appInstallationUpdatePolicyValues).optional(),
|
|
243
|
+
deploymentId: z.string().trim().min(1).optional(),
|
|
244
|
+
})
|
|
245
|
+
.strict();
|
|
246
|
+
export const lifecycleActionBodySchema = z
|
|
247
|
+
.object({
|
|
248
|
+
actorId: z.string().trim().min(1).max(160),
|
|
249
|
+
})
|
|
250
|
+
.strict();
|
|
251
|
+
export const activateInstallationBodySchema = z
|
|
252
|
+
.object({
|
|
253
|
+
releaseId: z.string().trim().min(1),
|
|
254
|
+
actorId: z.string().trim().min(1).max(160),
|
|
255
|
+
})
|
|
256
|
+
.strict();
|
|
216
257
|
export const appWebhookReplaySchema = z
|
|
217
258
|
.object({
|
|
218
259
|
deliveryId: z.string().trim().min(1),
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export * from "./consent.js";
|
|
|
7
7
|
export * from "./contracts.js";
|
|
8
8
|
export * from "./extension-resolution.js";
|
|
9
9
|
export * from "./ingestion.js";
|
|
10
|
+
export * from "./installation-read-model.js";
|
|
10
11
|
export * from "./installation-service.js";
|
|
11
12
|
export * from "./locale-resolution.js";
|
|
12
13
|
export * from "./oauth-crypto.js";
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ export * from "./consent.js";
|
|
|
7
7
|
export * from "./contracts.js";
|
|
8
8
|
export * from "./extension-resolution.js";
|
|
9
9
|
export * from "./ingestion.js";
|
|
10
|
+
export * from "./installation-read-model.js";
|
|
10
11
|
export * from "./installation-service.js";
|
|
11
12
|
export * from "./locale-resolution.js";
|
|
12
13
|
export * from "./oauth-crypto.js";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
2
|
+
import type { AppInstallationListQuery } from "./contracts.js";
|
|
3
|
+
import { type AppInstallation, type AppRegistration, type AppRelease, appAuditEvents, appExtensionInstallations, appGrants } from "./schema.js";
|
|
4
|
+
import { listAppWebhookHealth } from "./webhook-delivery.js";
|
|
5
|
+
export type AppGrantRow = typeof appGrants.$inferSelect;
|
|
6
|
+
export type AppExtensionInstallationRow = typeof appExtensionInstallations.$inferSelect;
|
|
7
|
+
export type AppAuditEventRow = typeof appAuditEvents.$inferSelect;
|
|
8
|
+
export type AppWebhookHealth = Awaited<ReturnType<typeof listAppWebhookHealth>>;
|
|
9
|
+
/**
|
|
10
|
+
* A single installation as shown in a governance list: the installation row
|
|
11
|
+
* plus the joined app and active-release descriptors the UI renders per row.
|
|
12
|
+
*/
|
|
13
|
+
export interface InstallationSummary extends AppInstallation {
|
|
14
|
+
appDisplayName: string;
|
|
15
|
+
appSlug: string;
|
|
16
|
+
distribution: AppRegistration["distribution"];
|
|
17
|
+
releaseVersion: string;
|
|
18
|
+
}
|
|
19
|
+
export interface InstallationListResult {
|
|
20
|
+
data: InstallationSummary[];
|
|
21
|
+
total: number;
|
|
22
|
+
limit: number;
|
|
23
|
+
offset: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A release the installation could move to, with whether governance can apply
|
|
27
|
+
* it without new consent and why it is blocked when it cannot.
|
|
28
|
+
*/
|
|
29
|
+
export interface AppAvailableUpdate {
|
|
30
|
+
release: AppRelease;
|
|
31
|
+
blocked: boolean;
|
|
32
|
+
blockedReason: string | null;
|
|
33
|
+
}
|
|
34
|
+
export interface InstallationDetail {
|
|
35
|
+
installation: AppInstallation;
|
|
36
|
+
app: AppRegistration;
|
|
37
|
+
activeRelease: AppRelease;
|
|
38
|
+
pendingRelease: AppRelease | null;
|
|
39
|
+
pendingReason: string | null;
|
|
40
|
+
grants: AppGrantRow[];
|
|
41
|
+
extensions: AppExtensionInstallationRow[];
|
|
42
|
+
webhooks: AppWebhookHealth;
|
|
43
|
+
recentAudit: AppAuditEventRow[];
|
|
44
|
+
availableUpdates: AppAvailableUpdate[];
|
|
45
|
+
}
|
|
46
|
+
export interface AvailableUpdateOptions {
|
|
47
|
+
platformApiVersion?: string;
|
|
48
|
+
}
|
|
49
|
+
export declare function listInstallationSummaries(db: PostgresJsDatabase, query: AppInstallationListQuery): Promise<InstallationListResult>;
|
|
50
|
+
export declare function listAppReleases(db: PostgresJsDatabase, appId: string): Promise<AppRelease[]>;
|
|
51
|
+
export declare function listInstallationAudit(db: PostgresJsDatabase, installationId: string, limit: number): Promise<AppAuditEventRow[]>;
|
|
52
|
+
export declare function loadInstallationDetail(db: PostgresJsDatabase, installationId: string, options?: AvailableUpdateOptions): Promise<InstallationDetail | null>;
|
|
53
|
+
/**
|
|
54
|
+
* Pure evaluation of which other available releases a governance operator can
|
|
55
|
+
* roll to. A candidate is blocked when it requires scopes the installation has
|
|
56
|
+
* not already granted, or when it falls outside the platform API range (only
|
|
57
|
+
* checked when a platform API version is known).
|
|
58
|
+
*/
|
|
59
|
+
export declare function computeAvailableUpdates(input: {
|
|
60
|
+
installation: Pick<AppInstallation, "releaseId">;
|
|
61
|
+
candidateReleases: readonly AppRelease[];
|
|
62
|
+
grantedScopes: ReadonlySet<string>;
|
|
63
|
+
platformApiVersion?: string;
|
|
64
|
+
}): AppAvailableUpdate[];
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { and, desc, eq, getTableColumns, sql } from "drizzle-orm";
|
|
2
|
+
import { appAuditEvents, appExtensionInstallations, appGrants, appInstallations, appReleases, apps, } from "./schema.js";
|
|
3
|
+
import { listAppWebhookHealth } from "./webhook-delivery.js";
|
|
4
|
+
export async function listInstallationSummaries(db, query) {
|
|
5
|
+
const where = and(query.appId ? eq(appInstallations.appId, query.appId) : undefined, query.status ? eq(appInstallations.status, query.status) : undefined, query.deploymentId ? eq(appInstallations.deploymentId, query.deploymentId) : undefined);
|
|
6
|
+
const [data, count] = await Promise.all([
|
|
7
|
+
db
|
|
8
|
+
.select({
|
|
9
|
+
...getTableColumns(appInstallations),
|
|
10
|
+
appDisplayName: apps.displayName,
|
|
11
|
+
appSlug: apps.slug,
|
|
12
|
+
distribution: apps.distribution,
|
|
13
|
+
releaseVersion: appReleases.releaseVersion,
|
|
14
|
+
})
|
|
15
|
+
.from(appInstallations)
|
|
16
|
+
.innerJoin(apps, eq(apps.id, appInstallations.appId))
|
|
17
|
+
.innerJoin(appReleases, eq(appReleases.id, appInstallations.releaseId))
|
|
18
|
+
.where(where)
|
|
19
|
+
.orderBy(desc(appInstallations.installedAt))
|
|
20
|
+
.limit(query.limit)
|
|
21
|
+
.offset(query.offset),
|
|
22
|
+
db.select({ count: sql `count(*)::int` }).from(appInstallations).where(where),
|
|
23
|
+
]);
|
|
24
|
+
return { data, total: count[0]?.count ?? 0, limit: query.limit, offset: query.offset };
|
|
25
|
+
}
|
|
26
|
+
export async function listAppReleases(db, appId) {
|
|
27
|
+
return db
|
|
28
|
+
.select()
|
|
29
|
+
.from(appReleases)
|
|
30
|
+
.where(eq(appReleases.appId, appId))
|
|
31
|
+
.orderBy(desc(appReleases.createdAt));
|
|
32
|
+
}
|
|
33
|
+
export async function listInstallationAudit(db, installationId, limit) {
|
|
34
|
+
return db
|
|
35
|
+
.select()
|
|
36
|
+
.from(appAuditEvents)
|
|
37
|
+
.where(eq(appAuditEvents.installationId, installationId))
|
|
38
|
+
.orderBy(desc(appAuditEvents.createdAt))
|
|
39
|
+
.limit(limit);
|
|
40
|
+
}
|
|
41
|
+
export async function loadInstallationDetail(db, installationId, options = {}) {
|
|
42
|
+
const [installation] = await db
|
|
43
|
+
.select()
|
|
44
|
+
.from(appInstallations)
|
|
45
|
+
.where(eq(appInstallations.id, installationId))
|
|
46
|
+
.limit(1);
|
|
47
|
+
if (!installation)
|
|
48
|
+
return null;
|
|
49
|
+
const [app] = await db.select().from(apps).where(eq(apps.id, installation.appId)).limit(1);
|
|
50
|
+
if (!app)
|
|
51
|
+
return null;
|
|
52
|
+
const [activeRelease] = await db
|
|
53
|
+
.select()
|
|
54
|
+
.from(appReleases)
|
|
55
|
+
.where(eq(appReleases.id, installation.releaseId))
|
|
56
|
+
.limit(1);
|
|
57
|
+
if (!activeRelease)
|
|
58
|
+
return null;
|
|
59
|
+
const [grants, extensions, webhooks, recentAudit, candidateReleases, pendingRelease] = await Promise.all([
|
|
60
|
+
db
|
|
61
|
+
.select()
|
|
62
|
+
.from(appGrants)
|
|
63
|
+
.where(eq(appGrants.installationId, installation.id))
|
|
64
|
+
.orderBy(appGrants.scope),
|
|
65
|
+
db
|
|
66
|
+
.select()
|
|
67
|
+
.from(appExtensionInstallations)
|
|
68
|
+
.where(eq(appExtensionInstallations.installationId, installation.id))
|
|
69
|
+
.orderBy(appExtensionInstallations.extensionKey),
|
|
70
|
+
listAppWebhookHealth(db, installation.id),
|
|
71
|
+
listInstallationAudit(db, installation.id, 20),
|
|
72
|
+
db
|
|
73
|
+
.select()
|
|
74
|
+
.from(appReleases)
|
|
75
|
+
.where(and(eq(appReleases.appId, installation.appId), eq(appReleases.state, "available"))),
|
|
76
|
+
loadPendingRelease(db, installation.pendingReleaseId),
|
|
77
|
+
]);
|
|
78
|
+
const grantedScopes = new Set(grants.filter((grant) => grant.status === "granted").map((grant) => grant.scope));
|
|
79
|
+
const availableUpdates = computeAvailableUpdates({
|
|
80
|
+
installation,
|
|
81
|
+
candidateReleases,
|
|
82
|
+
grantedScopes,
|
|
83
|
+
platformApiVersion: options.platformApiVersion,
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
installation,
|
|
87
|
+
app,
|
|
88
|
+
activeRelease,
|
|
89
|
+
pendingRelease,
|
|
90
|
+
pendingReason: installation.pendingReason ?? null,
|
|
91
|
+
grants,
|
|
92
|
+
extensions,
|
|
93
|
+
webhooks,
|
|
94
|
+
recentAudit,
|
|
95
|
+
availableUpdates,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Pure evaluation of which other available releases a governance operator can
|
|
100
|
+
* roll to. A candidate is blocked when it requires scopes the installation has
|
|
101
|
+
* not already granted, or when it falls outside the platform API range (only
|
|
102
|
+
* checked when a platform API version is known).
|
|
103
|
+
*/
|
|
104
|
+
export function computeAvailableUpdates(input) {
|
|
105
|
+
const updates = [];
|
|
106
|
+
for (const release of input.candidateReleases) {
|
|
107
|
+
if (release.id === input.installation.releaseId)
|
|
108
|
+
continue;
|
|
109
|
+
updates.push({
|
|
110
|
+
release,
|
|
111
|
+
...evaluateUpdateBlock(release, input.grantedScopes, input.platformApiVersion),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return updates;
|
|
115
|
+
}
|
|
116
|
+
function evaluateUpdateBlock(release, grantedScopes, platformApiVersion) {
|
|
117
|
+
const required = readRequestedScopes(release.normalizedRecord);
|
|
118
|
+
const missing = required.filter((scope) => !grantedScopes.has(scope));
|
|
119
|
+
if (missing.length > 0) {
|
|
120
|
+
return {
|
|
121
|
+
blocked: true,
|
|
122
|
+
blockedReason: `New required scopes need consent: ${missing.join(", ")}`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (platformApiVersion && !isApiCompatible(release.apiCompatibility, platformApiVersion)) {
|
|
126
|
+
return { blocked: true, blockedReason: "Release is not API-compatible with this platform" };
|
|
127
|
+
}
|
|
128
|
+
return { blocked: false, blockedReason: null };
|
|
129
|
+
}
|
|
130
|
+
function isApiCompatible(range, platformApiVersion) {
|
|
131
|
+
return platformApiVersion >= range.min && platformApiVersion <= range.max;
|
|
132
|
+
}
|
|
133
|
+
async function loadPendingRelease(db, pendingReleaseId) {
|
|
134
|
+
if (!pendingReleaseId)
|
|
135
|
+
return null;
|
|
136
|
+
const [row] = await db
|
|
137
|
+
.select()
|
|
138
|
+
.from(appReleases)
|
|
139
|
+
.where(eq(appReleases.id, pendingReleaseId))
|
|
140
|
+
.limit(1);
|
|
141
|
+
return row ?? null;
|
|
142
|
+
}
|
|
143
|
+
function readRequestedScopes(normalizedRecord) {
|
|
144
|
+
const value = normalizedRecord.requestedScopes;
|
|
145
|
+
if (!Array.isArray(value))
|
|
146
|
+
return [];
|
|
147
|
+
return value.filter((item) => typeof item === "string");
|
|
148
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { handleApiError } from "@voyant-travel/hono";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { createAppsAdminRoutes } from "./routes.js";
|
|
5
|
+
import { createAppsService } from "./service.js";
|
|
6
|
+
import { validManifest } from "./test-fixtures.js";
|
|
7
|
+
const DB_AVAILABLE = !!process.env.TEST_DATABASE_URL;
|
|
8
|
+
const DEPLOYMENT_ID = "deployment_test";
|
|
9
|
+
const json = (body) => ({
|
|
10
|
+
method: "POST",
|
|
11
|
+
headers: { "Content-Type": "application/json" },
|
|
12
|
+
body: JSON.stringify(body),
|
|
13
|
+
});
|
|
14
|
+
describe.skipIf(!DB_AVAILABLE)("apps installation admin routes", () => {
|
|
15
|
+
let app;
|
|
16
|
+
let db;
|
|
17
|
+
beforeAll(async () => {
|
|
18
|
+
const { createTestDb } = await import("@voyant-travel/db/test-utils");
|
|
19
|
+
db = createTestDb();
|
|
20
|
+
app = new Hono();
|
|
21
|
+
app.onError((err, c) => handleApiError(err, c));
|
|
22
|
+
app.use("*", async (c, next) => {
|
|
23
|
+
c.set("db", db);
|
|
24
|
+
await next();
|
|
25
|
+
});
|
|
26
|
+
app.route("/", createAppsAdminRoutes({ deploymentId: DEPLOYMENT_ID }));
|
|
27
|
+
});
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
const { cleanupTestDb } = await import("@voyant-travel/db/test-utils");
|
|
30
|
+
await cleanupTestDb(db);
|
|
31
|
+
});
|
|
32
|
+
async function seedAppWithReleases() {
|
|
33
|
+
const service = createAppsService();
|
|
34
|
+
const registration = await service.createCustomApp(db, {
|
|
35
|
+
ownerId: "owner_1",
|
|
36
|
+
displayName: "Acme Sync",
|
|
37
|
+
slug: "acme-sync",
|
|
38
|
+
redirectUris: [],
|
|
39
|
+
createdBy: "user_1",
|
|
40
|
+
});
|
|
41
|
+
const v1 = await service.releaseFromUpload(db, registration.id, {
|
|
42
|
+
manifest: validManifest,
|
|
43
|
+
createdBy: "user_1",
|
|
44
|
+
provenance: { source: "test" },
|
|
45
|
+
});
|
|
46
|
+
const v2 = await service.releaseFromUpload(db, registration.id, {
|
|
47
|
+
manifest: {
|
|
48
|
+
...validManifest,
|
|
49
|
+
releaseVersion: "2.0.0",
|
|
50
|
+
scopes: { requested: ["bookings:read", "customers:read"], optional: ["invoices:read"] },
|
|
51
|
+
},
|
|
52
|
+
createdBy: "user_1",
|
|
53
|
+
provenance: { source: "test" },
|
|
54
|
+
});
|
|
55
|
+
return { appId: registration.id, releaseV1: v1.release, releaseV2: v2.release };
|
|
56
|
+
}
|
|
57
|
+
async function installV1(appId, releaseId) {
|
|
58
|
+
const response = await app.request("/install", json({ appId, releaseId, actorId: "actor_1" }));
|
|
59
|
+
expect(response.status).toBe(201);
|
|
60
|
+
const payload = (await response.json());
|
|
61
|
+
return payload.data.installation;
|
|
62
|
+
}
|
|
63
|
+
it("lists installation summaries with joined app + release descriptors", async () => {
|
|
64
|
+
const { appId, releaseV1 } = await seedAppWithReleases();
|
|
65
|
+
await installV1(appId, releaseV1.id);
|
|
66
|
+
const response = await app.request("/installations");
|
|
67
|
+
expect(response.status).toBe(200);
|
|
68
|
+
const body = (await response.json());
|
|
69
|
+
expect(body.total).toBe(1);
|
|
70
|
+
expect(body.limit).toBe(25);
|
|
71
|
+
expect(body.offset).toBe(0);
|
|
72
|
+
const [summary] = body.data;
|
|
73
|
+
expect(summary).toMatchObject({
|
|
74
|
+
appId,
|
|
75
|
+
status: "active",
|
|
76
|
+
appDisplayName: "Acme Sync",
|
|
77
|
+
appSlug: "acme-sync",
|
|
78
|
+
distribution: "custom",
|
|
79
|
+
releaseVersion: "1.0.0",
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
it("aggregates installation detail with grants, extensions, webhooks, audit and blocked updates", async () => {
|
|
83
|
+
const { appId, releaseV1, releaseV2 } = await seedAppWithReleases();
|
|
84
|
+
const installation = await installV1(appId, releaseV1.id);
|
|
85
|
+
const response = await app.request(`/installations/${installation.id}`);
|
|
86
|
+
expect(response.status).toBe(200);
|
|
87
|
+
const body = (await response.json());
|
|
88
|
+
const detail = body.data;
|
|
89
|
+
expect(detail.installation.id).toBe(installation.id);
|
|
90
|
+
expect(detail.app.id).toBe(appId);
|
|
91
|
+
expect(detail.activeRelease.id).toBe(releaseV1.id);
|
|
92
|
+
expect(detail.pendingRelease).toBeNull();
|
|
93
|
+
expect(detail.pendingReason).toBeNull();
|
|
94
|
+
// grants are ordered by scope; bookings:read granted, invoices:read optional
|
|
95
|
+
expect(detail.grants.map((g) => g.scope)).toEqual(["bookings:read", "invoices:read"]);
|
|
96
|
+
const bookings = detail.grants.find((g) => g.scope === "bookings:read");
|
|
97
|
+
expect(bookings?.status).toBe("granted");
|
|
98
|
+
expect(detail.extensions.length).toBeGreaterThan(0);
|
|
99
|
+
expect(detail.webhooks.data.map((w) => w.eventType)).toContain("booking.created");
|
|
100
|
+
expect(detail.recentAudit.length).toBeGreaterThan(0);
|
|
101
|
+
// v2 introduces customers:read which was never granted -> blocked
|
|
102
|
+
const v2Update = detail.availableUpdates.find((u) => u.release.id === releaseV2.id);
|
|
103
|
+
expect(v2Update).toBeDefined();
|
|
104
|
+
expect(v2Update?.blocked).toBe(true);
|
|
105
|
+
expect(v2Update?.blockedReason).toBe("New required scopes need consent: customers:read");
|
|
106
|
+
// the active release is never offered as an update
|
|
107
|
+
expect(detail.availableUpdates.some((u) => u.release.id === releaseV1.id)).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
it("returns 404 for an unknown installation", async () => {
|
|
110
|
+
const response = await app.request("/installations/app_installations_missing");
|
|
111
|
+
expect(response.status).toBe(404);
|
|
112
|
+
const body = (await response.json());
|
|
113
|
+
expect(body.error).toBe("App installation not found");
|
|
114
|
+
});
|
|
115
|
+
it("exposes the installation audit trail newest-first", async () => {
|
|
116
|
+
const { appId, releaseV1 } = await seedAppWithReleases();
|
|
117
|
+
const installation = await installV1(appId, releaseV1.id);
|
|
118
|
+
const response = await app.request(`/installations/${installation.id}/audit?limit=5`);
|
|
119
|
+
expect(response.status).toBe(200);
|
|
120
|
+
const body = (await response.json());
|
|
121
|
+
expect(body.data.length).toBeGreaterThan(0);
|
|
122
|
+
const timestamps = body.data.map((row) => row.createdAt);
|
|
123
|
+
const sorted = [...timestamps].sort((a, b) => (a < b ? 1 : -1));
|
|
124
|
+
expect(timestamps).toEqual(sorted);
|
|
125
|
+
});
|
|
126
|
+
it("lists all releases for an app newest-first", async () => {
|
|
127
|
+
const { appId, releaseV2 } = await seedAppWithReleases();
|
|
128
|
+
const response = await app.request(`/${appId}/releases`);
|
|
129
|
+
expect(response.status).toBe(200);
|
|
130
|
+
const body = (await response.json());
|
|
131
|
+
expect(body.data).toHaveLength(2);
|
|
132
|
+
expect(body.data[0]?.id).toBe(releaseV2.id);
|
|
133
|
+
});
|
|
134
|
+
it("drives pause -> resume -> uninstall then purge-preview", async () => {
|
|
135
|
+
const { appId, releaseV1 } = await seedAppWithReleases();
|
|
136
|
+
const installation = await installV1(appId, releaseV1.id);
|
|
137
|
+
const paused = await app.request(`/installations/${installation.id}/pause`, json({ actorId: "actor_1" }));
|
|
138
|
+
expect(paused.status).toBe(200);
|
|
139
|
+
expect((await paused.json()).data.installation.status).toBe("paused");
|
|
140
|
+
const resumed = await app.request(`/installations/${installation.id}/resume`, json({ actorId: "actor_1" }));
|
|
141
|
+
expect(resumed.status).toBe(200);
|
|
142
|
+
expect((await resumed.json()).data.installation.status).toBe("active");
|
|
143
|
+
const uninstalled = await app.request(`/installations/${installation.id}/uninstall`, json({ actorId: "actor_1" }));
|
|
144
|
+
expect(uninstalled.status).toBe(200);
|
|
145
|
+
expect((await uninstalled.json()).data.installation.status).toBe("uninstalled");
|
|
146
|
+
const purge = await app.request(`/installations/${installation.id}/purge-preview`, json({ actorId: "actor_1" }));
|
|
147
|
+
expect(purge.status).toBe(200);
|
|
148
|
+
const purgeBody = (await purge.json());
|
|
149
|
+
expect(purgeBody.data.grants).toBeGreaterThan(0);
|
|
150
|
+
expect(purgeBody.data.extensions).toBeGreaterThan(0);
|
|
151
|
+
expect(purgeBody.data.webhooks).toBeGreaterThan(0);
|
|
152
|
+
});
|
|
153
|
+
it("creates an active installation via /install", async () => {
|
|
154
|
+
const { appId, releaseV1 } = await seedAppWithReleases();
|
|
155
|
+
const response = await app.request("/install", json({ appId, releaseId: releaseV1.id, actorId: "actor_1" }));
|
|
156
|
+
expect(response.status).toBe(201);
|
|
157
|
+
const body = (await response.json());
|
|
158
|
+
expect(body.data.installation.status).toBe("active");
|
|
159
|
+
expect(body.data.outcome).toBe("created");
|
|
160
|
+
});
|
|
161
|
+
});
|
package/dist/routes.d.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
+
import type { EventBus } from "@voyant-travel/core/events";
|
|
2
|
+
import type { createCustomFieldsService } from "@voyant-travel/custom-fields";
|
|
1
3
|
import type { AccessCatalog } from "@voyant-travel/types/api-keys";
|
|
2
4
|
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
3
5
|
import { Hono } from "hono";
|
|
4
6
|
import { type AppsServiceOptions } from "./service.js";
|
|
7
|
+
type CustomFieldsService = ReturnType<typeof createCustomFieldsService>;
|
|
5
8
|
type Env = {
|
|
9
|
+
Bindings: {
|
|
10
|
+
/** Deployment identity; the install lifecycle is scoped to it. */
|
|
11
|
+
VOYANT_CLOUD_DEPLOYMENT_ID?: string;
|
|
12
|
+
};
|
|
6
13
|
Variables: {
|
|
7
14
|
db: PostgresJsDatabase;
|
|
8
15
|
};
|
|
@@ -21,6 +28,15 @@ export interface AppsAdminRouteOptions extends AppsServiceOptions {
|
|
|
21
28
|
secret: string;
|
|
22
29
|
ttlSeconds?: number;
|
|
23
30
|
};
|
|
31
|
+
/**
|
|
32
|
+
* Deployment identity used when installing over HTTP. Falls back to
|
|
33
|
+
* {@link oauth}'s deployment id when omitted.
|
|
34
|
+
*/
|
|
35
|
+
deploymentId?: string;
|
|
36
|
+
/** Platform API version used to gate release compatibility. */
|
|
37
|
+
platformApiVersion?: string;
|
|
38
|
+
eventBus?: EventBus;
|
|
39
|
+
customFields?: CustomFieldsService;
|
|
24
40
|
}
|
|
25
41
|
export declare function createAppsAdminRoutes(options?: AppsAdminRouteOptions): Hono<Env, import("hono/types").BlankSchema, "/">;
|
|
26
42
|
export {};
|
package/dist/routes.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { parseJsonBody, parseQuery, RequestValidationError, requireUserId, } from "@voyant-travel/hono";
|
|
2
2
|
import { Hono } from "hono";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import { appCredentialRevocationSchema, appListQuerySchema, appOAuthAuthorizeQuerySchema, appOAuthTokenSchema, appSessionTokenExchangeSchema, appSessionTokenIssueSchema, appWebhookReplaySchema, createCustomAppRegistrationSchema, releaseManifestFetchSchema, releaseManifestUploadSchema, } from "./contracts.js";
|
|
4
|
+
import { activateInstallationBodySchema, appCredentialRevocationSchema, appInstallationAuditQuerySchema, appInstallationListQuerySchema, appListQuerySchema, appOAuthAuthorizeQuerySchema, appOAuthTokenSchema, appSessionTokenExchangeSchema, appSessionTokenIssueSchema, appWebhookReplaySchema, createCustomAppRegistrationSchema, installAppSchema, lifecycleActionBodySchema, releaseManifestFetchSchema, releaseManifestUploadSchema, } from "./contracts.js";
|
|
5
|
+
import { listAppReleases, listInstallationAudit, listInstallationSummaries, loadInstallationDetail, } from "./installation-read-model.js";
|
|
6
|
+
import { createAppInstallationService } from "./installation-service.js";
|
|
5
7
|
import { createAppOAuthService } from "./oauth-service.js";
|
|
6
8
|
import { createAppsService } from "./service.js";
|
|
7
9
|
import { createAppSessionTokenService } from "./session-token-service.js";
|
|
@@ -11,6 +13,12 @@ const installationIdParamSchema = z.object({ installationId: z.string().min(1) }
|
|
|
11
13
|
export function createAppsAdminRoutes(options = {}) {
|
|
12
14
|
const routes = new Hono();
|
|
13
15
|
const service = createAppsService(options);
|
|
16
|
+
const installations = createAppInstallationService({
|
|
17
|
+
deploymentId: options.oauth?.deploymentId ?? options.deploymentId,
|
|
18
|
+
platformApiVersion: options.platformApiVersion,
|
|
19
|
+
eventBus: options.eventBus,
|
|
20
|
+
customFields: options.customFields,
|
|
21
|
+
});
|
|
14
22
|
const oauth = options.oauth ? createAppOAuthService(options.oauth) : null;
|
|
15
23
|
const sessionTokens = oauth && options.oauth && options.sessionToken
|
|
16
24
|
? createAppSessionTokenService({
|
|
@@ -122,11 +130,95 @@ export function createAppsAdminRoutes(options = {}) {
|
|
|
122
130
|
});
|
|
123
131
|
return c.json(token, 200);
|
|
124
132
|
});
|
|
133
|
+
routes.post("/install", async (c) => {
|
|
134
|
+
const body = await parseJsonBody(c, installAppSchema);
|
|
135
|
+
// The deployment id is a runtime value (not known at graph-composition
|
|
136
|
+
// time), so resolve it per request: explicit body → runtime env →
|
|
137
|
+
// construction option. Without this the standard runtime mounts these
|
|
138
|
+
// routes with no deployment id and every install 400s (app_deployment_required).
|
|
139
|
+
const deploymentId = body.deploymentId ?? c.env?.VOYANT_CLOUD_DEPLOYMENT_ID?.trim() ?? options.deploymentId;
|
|
140
|
+
const result = await installations.install(c.get("db"), {
|
|
141
|
+
appId: body.appId,
|
|
142
|
+
releaseId: body.releaseId,
|
|
143
|
+
actorId: body.actorId,
|
|
144
|
+
grantedOptionalScopes: body.grantedOptionalScopes,
|
|
145
|
+
updatePolicy: body.updatePolicy,
|
|
146
|
+
deploymentId,
|
|
147
|
+
});
|
|
148
|
+
return c.json({ data: result }, 201);
|
|
149
|
+
});
|
|
150
|
+
routes.get("/installations", async (c) => {
|
|
151
|
+
const query = parseQuery(c, appInstallationListQuerySchema);
|
|
152
|
+
return c.json(await listInstallationSummaries(c.get("db"), query), 200);
|
|
153
|
+
});
|
|
154
|
+
routes.get("/installations/:installationId", async (c) => {
|
|
155
|
+
const { installationId } = parseInstallationParams(c.req.param());
|
|
156
|
+
const detail = await loadInstallationDetail(c.get("db"), installationId, {
|
|
157
|
+
platformApiVersion: options.platformApiVersion,
|
|
158
|
+
});
|
|
159
|
+
return detail
|
|
160
|
+
? c.json({ data: detail }, 200)
|
|
161
|
+
: c.json({ error: "App installation not found" }, 404);
|
|
162
|
+
});
|
|
163
|
+
routes.get("/installations/:installationId/audit", async (c) => {
|
|
164
|
+
const { installationId } = parseInstallationParams(c.req.param());
|
|
165
|
+
const query = parseQuery(c, appInstallationAuditQuerySchema);
|
|
166
|
+
const data = await listInstallationAudit(c.get("db"), installationId, query.limit);
|
|
167
|
+
return c.json({ data }, 200);
|
|
168
|
+
});
|
|
169
|
+
routes.post("/installations/:installationId/pause", async (c) => {
|
|
170
|
+
const { installationId } = parseInstallationParams(c.req.param());
|
|
171
|
+
const body = await parseJsonBody(c, lifecycleActionBodySchema);
|
|
172
|
+
const result = await installations.pause(c.get("db"), { installationId, actorId: body.actorId });
|
|
173
|
+
return c.json({ data: result }, 200);
|
|
174
|
+
});
|
|
175
|
+
routes.post("/installations/:installationId/resume", async (c) => {
|
|
176
|
+
const { installationId } = parseInstallationParams(c.req.param());
|
|
177
|
+
const body = await parseJsonBody(c, lifecycleActionBodySchema);
|
|
178
|
+
const result = await installations.resume(c.get("db"), {
|
|
179
|
+
installationId,
|
|
180
|
+
actorId: body.actorId,
|
|
181
|
+
});
|
|
182
|
+
return c.json({ data: result }, 200);
|
|
183
|
+
});
|
|
184
|
+
routes.post("/installations/:installationId/uninstall", async (c) => {
|
|
185
|
+
const { installationId } = parseInstallationParams(c.req.param());
|
|
186
|
+
const body = await parseJsonBody(c, lifecycleActionBodySchema);
|
|
187
|
+
const result = await installations.uninstall(c.get("db"), {
|
|
188
|
+
installationId,
|
|
189
|
+
actorId: body.actorId,
|
|
190
|
+
});
|
|
191
|
+
return c.json({ data: result }, 200);
|
|
192
|
+
});
|
|
193
|
+
routes.post("/installations/:installationId/activate", async (c) => {
|
|
194
|
+
const { installationId } = parseInstallationParams(c.req.param());
|
|
195
|
+
const body = await parseJsonBody(c, activateInstallationBodySchema);
|
|
196
|
+
const result = await installations.upgrade(c.get("db"), {
|
|
197
|
+
installationId,
|
|
198
|
+
releaseId: body.releaseId,
|
|
199
|
+
actorId: body.actorId,
|
|
200
|
+
});
|
|
201
|
+
return c.json({ data: result }, 200);
|
|
202
|
+
});
|
|
203
|
+
routes.post("/installations/:installationId/purge-preview", async (c) => {
|
|
204
|
+
const { installationId } = parseInstallationParams(c.req.param());
|
|
205
|
+
const body = await parseJsonBody(c, lifecycleActionBodySchema);
|
|
206
|
+
const result = await installations.purgePreview(c.get("db"), {
|
|
207
|
+
installationId,
|
|
208
|
+
actorId: body.actorId,
|
|
209
|
+
});
|
|
210
|
+
return c.json({ data: result }, 200);
|
|
211
|
+
});
|
|
125
212
|
routes.get("/:appId", async (c) => {
|
|
126
213
|
const { appId } = parseParams(c.req.param());
|
|
127
214
|
const app = await service.get(c.get("db"), appId);
|
|
128
215
|
return app ? c.json({ data: app }, 200) : c.json({ error: "App not found" }, 404);
|
|
129
216
|
});
|
|
217
|
+
routes.get("/:appId/releases", async (c) => {
|
|
218
|
+
const { appId } = parseParams(c.req.param());
|
|
219
|
+
const data = await listAppReleases(c.get("db"), appId);
|
|
220
|
+
return c.json({ data }, 200);
|
|
221
|
+
});
|
|
130
222
|
routes.post("/:appId/releases", async (c) => {
|
|
131
223
|
const { appId } = parseParams(c.req.param());
|
|
132
224
|
const body = await parseJsonBody(c, releaseManifestUploadSchema);
|
package/dist/voyant.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { defineModule, requirePort } from "@voyant-travel/core/project";
|
|
2
2
|
import { customFieldValueLifecycleRuntimePort, customFieldValueOperationsRuntimePort, } from "@voyant-travel/core/runtime-port";
|
|
3
|
+
const appsAdminRuntime = {
|
|
4
|
+
entry: "@voyant-travel/apps-react/admin",
|
|
5
|
+
export: "createSelectedAppsAdminExtension",
|
|
6
|
+
};
|
|
3
7
|
const appInstallationLifecyclePayloadSchema = {
|
|
4
8
|
type: "object",
|
|
5
9
|
additionalProperties: false,
|
|
@@ -120,8 +124,11 @@ export const appsVoyantModule = defineModule({
|
|
|
120
124
|
wildcard: "explicit-resource",
|
|
121
125
|
},
|
|
122
126
|
{
|
|
123
|
-
id: "@voyant-travel/apps#access.webhooks",
|
|
124
|
-
|
|
127
|
+
id: "@voyant-travel/apps#access.app-webhooks",
|
|
128
|
+
// Namespaced `app-webhooks` (not `webhooks`) to avoid a duplicate
|
|
129
|
+
// access-resource authority with @voyant-travel/workflow-runs, which
|
|
130
|
+
// owns the deployment `webhooks` resource.
|
|
131
|
+
resource: "app-webhooks",
|
|
125
132
|
label: "App webhooks",
|
|
126
133
|
description: "Read webhook health and request replay.",
|
|
127
134
|
remoteSafe: true,
|
|
@@ -189,6 +196,49 @@ export const appsVoyantModule = defineModule({
|
|
|
189
196
|
},
|
|
190
197
|
],
|
|
191
198
|
},
|
|
199
|
+
admin: {
|
|
200
|
+
compositionOrder: 170,
|
|
201
|
+
runtime: appsAdminRuntime,
|
|
202
|
+
copy: [
|
|
203
|
+
{
|
|
204
|
+
id: "@voyant-travel/apps#admin.copy",
|
|
205
|
+
namespace: "apps.admin",
|
|
206
|
+
fallbackLocale: "en",
|
|
207
|
+
runtime: {
|
|
208
|
+
entry: "@voyant-travel/apps-react/i18n",
|
|
209
|
+
export: "appsUiMessageDefinitions",
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
routes: [
|
|
214
|
+
{
|
|
215
|
+
id: "@voyant-travel/apps#admin.route.installed",
|
|
216
|
+
path: "/apps",
|
|
217
|
+
requiredScopes: ["apps:read"],
|
|
218
|
+
runtime: appsAdminRuntime,
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
id: "@voyant-travel/apps#admin.route.developer",
|
|
222
|
+
path: "/apps/developer",
|
|
223
|
+
requiredScopes: ["apps:write"],
|
|
224
|
+
runtime: appsAdminRuntime,
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
nav: [
|
|
228
|
+
{
|
|
229
|
+
id: "@voyant-travel/apps#admin.nav.installed",
|
|
230
|
+
routeId: "@voyant-travel/apps#admin.route.installed",
|
|
231
|
+
label: { namespace: "apps.admin", key: "navigation.title" },
|
|
232
|
+
order: 170,
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
id: "@voyant-travel/apps#admin.nav.developer",
|
|
236
|
+
routeId: "@voyant-travel/apps#admin.route.developer",
|
|
237
|
+
label: { namespace: "apps.admin", key: "navigation.developerTitle" },
|
|
238
|
+
order: 171,
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
},
|
|
192
242
|
lifecycle: { uninstall: { default: "retain-data", purge: "not-supported" } },
|
|
193
243
|
meta: {
|
|
194
244
|
ownership: "package",
|