@voyant-travel/apps 0.4.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 +71 -0
- package/dist/contracts.js +66 -0
- package/dist/extension-resolution.d.ts +72 -0
- package/dist/extension-resolution.js +159 -0
- package/dist/extension-resolution.test.d.ts +1 -0
- package/dist/extension-resolution.test.js +111 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +6 -1
- 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/locale-resolution.d.ts +59 -0
- package/dist/locale-resolution.js +113 -0
- package/dist/locale-resolution.test.d.ts +1 -0
- package/dist/locale-resolution.test.js +48 -0
- package/dist/routes.d.ts +25 -0
- package/dist/routes.js +135 -2
- package/dist/schema.d.ts +237 -0
- package/dist/schema.js +27 -0
- package/dist/session-token-service.d.ts +47 -0
- package/dist/session-token-service.js +127 -0
- package/dist/session-token.d.ts +82 -0
- package/dist/session-token.js +0 -0
- package/dist/session-token.test.d.ts +1 -0
- package/dist/session-token.test.js +97 -0
- package/dist/voyant.js +52 -2
- package/migrations/20260717150000_app_session_tokens.sql +18 -0
- package/migrations/meta/_journal.json +8 -1
- package/package.json +23 -3
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic app-locale resolution for the extension host (RFC §6.1).
|
|
3
|
+
*
|
|
4
|
+
* The host always passes the staff member's active locale to the frame. It also
|
|
5
|
+
* resolves an "app locale" against the installed release's declared locales so
|
|
6
|
+
* host-rendered labels (nav entries, extension titles) and the frame's initial
|
|
7
|
+
* direction are chosen consistently:
|
|
8
|
+
*
|
|
9
|
+
* 1. exact active-locale match;
|
|
10
|
+
* 2. progressively less specific language match (`pt-BR` → `pt`);
|
|
11
|
+
* 3. the app's declared default locale.
|
|
12
|
+
*
|
|
13
|
+
* Everything here is pure and BCP 47 aware only to the extent of splitting on
|
|
14
|
+
* the primary subtag — the app remains authoritative for its in-frame
|
|
15
|
+
* translations and may choose a more sophisticated fallback itself.
|
|
16
|
+
*/
|
|
17
|
+
export type AppTextDirection = "ltr" | "rtl";
|
|
18
|
+
/** Text direction for a locale, from its primary language subtag. */
|
|
19
|
+
export declare function resolveTextDirection(locale: string): AppTextDirection;
|
|
20
|
+
export interface AppLocaleDeclaration {
|
|
21
|
+
defaultLocale: string;
|
|
22
|
+
supportedLocales: readonly string[];
|
|
23
|
+
}
|
|
24
|
+
export interface ResolvedAppLocale {
|
|
25
|
+
/** The active admin locale the host requested resolution for. */
|
|
26
|
+
requestedLocale: string;
|
|
27
|
+
/** The declared locale the host resolved to (exact → language → default). */
|
|
28
|
+
appLocale: string;
|
|
29
|
+
/** Text direction for {@link appLocale}. */
|
|
30
|
+
direction: AppTextDirection;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the app locale for an active admin locale against a release's
|
|
34
|
+
* declared locales. The returned `appLocale` is always one of the declared
|
|
35
|
+
* tags (falling back to `defaultLocale`), preserving the declared casing.
|
|
36
|
+
*/
|
|
37
|
+
export declare function resolveAppLocale(activeLocale: string, declaration: AppLocaleDeclaration): ResolvedAppLocale;
|
|
38
|
+
/** One flattened host-rendered localization row (matches `app_release_localizations`). */
|
|
39
|
+
export interface HostLabelRow {
|
|
40
|
+
locale: string;
|
|
41
|
+
surface: string;
|
|
42
|
+
messageKey: string;
|
|
43
|
+
text: string;
|
|
44
|
+
}
|
|
45
|
+
export interface HostLabelResolver {
|
|
46
|
+
/**
|
|
47
|
+
* Resolve a host-rendered label for a message key, trying the resolved app
|
|
48
|
+
* locale first and then the default locale (host labels use the platform
|
|
49
|
+
* algorithm, never the app's in-frame fallback). Returns null when no
|
|
50
|
+
* declared surface carries the key.
|
|
51
|
+
*/
|
|
52
|
+
resolve(messageKey: string, surfaces?: readonly string[]): string | null;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Build a resolver over a release's localization rows for a resolved app locale
|
|
56
|
+
* with default-locale fallback. Missing translations fall back deterministically
|
|
57
|
+
* rather than throwing, so an incomplete non-default locale never blocks mount.
|
|
58
|
+
*/
|
|
59
|
+
export declare function createHostLabelResolver(rows: readonly HostLabelRow[], appLocale: string, defaultLocale: string): HostLabelResolver;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic app-locale resolution for the extension host (RFC §6.1).
|
|
3
|
+
*
|
|
4
|
+
* The host always passes the staff member's active locale to the frame. It also
|
|
5
|
+
* resolves an "app locale" against the installed release's declared locales so
|
|
6
|
+
* host-rendered labels (nav entries, extension titles) and the frame's initial
|
|
7
|
+
* direction are chosen consistently:
|
|
8
|
+
*
|
|
9
|
+
* 1. exact active-locale match;
|
|
10
|
+
* 2. progressively less specific language match (`pt-BR` → `pt`);
|
|
11
|
+
* 3. the app's declared default locale.
|
|
12
|
+
*
|
|
13
|
+
* Everything here is pure and BCP 47 aware only to the extent of splitting on
|
|
14
|
+
* the primary subtag — the app remains authoritative for its in-frame
|
|
15
|
+
* translations and may choose a more sophisticated fallback itself.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Primary language subtags written right-to-left. Kept small and explicit; the
|
|
19
|
+
* frame receives the resolved direction so it never has to infer it from an
|
|
20
|
+
* incomplete language list.
|
|
21
|
+
*/
|
|
22
|
+
const RTL_LANGUAGES = new Set([
|
|
23
|
+
"ar", // Arabic
|
|
24
|
+
"arc", // Aramaic
|
|
25
|
+
"ckb", // Central Kurdish (Sorani)
|
|
26
|
+
"dv", // Divehi
|
|
27
|
+
"fa", // Persian
|
|
28
|
+
"he", // Hebrew
|
|
29
|
+
"ku", // Kurdish
|
|
30
|
+
"ps", // Pashto
|
|
31
|
+
"sd", // Sindhi
|
|
32
|
+
"syr", // Syriac
|
|
33
|
+
"ur", // Urdu
|
|
34
|
+
"yi", // Yiddish
|
|
35
|
+
]);
|
|
36
|
+
/** The primary language subtag of a BCP 47 tag, lowercased. */
|
|
37
|
+
function primaryLanguage(locale) {
|
|
38
|
+
return locale.trim().toLowerCase().split(/[-_]/)[0] ?? "";
|
|
39
|
+
}
|
|
40
|
+
/** Text direction for a locale, from its primary language subtag. */
|
|
41
|
+
export function resolveTextDirection(locale) {
|
|
42
|
+
return RTL_LANGUAGES.has(primaryLanguage(locale)) ? "rtl" : "ltr";
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the app locale for an active admin locale against a release's
|
|
46
|
+
* declared locales. The returned `appLocale` is always one of the declared
|
|
47
|
+
* tags (falling back to `defaultLocale`), preserving the declared casing.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveAppLocale(activeLocale, declaration) {
|
|
50
|
+
const requested = activeLocale.trim();
|
|
51
|
+
const normalized = requested.toLowerCase();
|
|
52
|
+
const supported = declaration.supportedLocales;
|
|
53
|
+
const exact = supported.find((candidate) => candidate.toLowerCase() === normalized);
|
|
54
|
+
if (exact) {
|
|
55
|
+
return { requestedLocale: requested, appLocale: exact, direction: resolveTextDirection(exact) };
|
|
56
|
+
}
|
|
57
|
+
const requestedLanguage = primaryLanguage(requested);
|
|
58
|
+
if (requestedLanguage) {
|
|
59
|
+
const languageMatch = supported.find((candidate) => primaryLanguage(candidate) === requestedLanguage);
|
|
60
|
+
if (languageMatch) {
|
|
61
|
+
return {
|
|
62
|
+
requestedLocale: requested,
|
|
63
|
+
appLocale: languageMatch,
|
|
64
|
+
direction: resolveTextDirection(languageMatch),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
requestedLocale: requested,
|
|
70
|
+
appLocale: declaration.defaultLocale,
|
|
71
|
+
direction: resolveTextDirection(declaration.defaultLocale),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const DEFAULT_LABEL_SURFACES = ["extension", "navigation", "setup", "app"];
|
|
75
|
+
/**
|
|
76
|
+
* Build a resolver over a release's localization rows for a resolved app locale
|
|
77
|
+
* with default-locale fallback. Missing translations fall back deterministically
|
|
78
|
+
* rather than throwing, so an incomplete non-default locale never blocks mount.
|
|
79
|
+
*/
|
|
80
|
+
export function createHostLabelResolver(rows, appLocale, defaultLocale) {
|
|
81
|
+
// locale → surface → messageKey → text
|
|
82
|
+
const byLocale = new Map();
|
|
83
|
+
for (const row of rows) {
|
|
84
|
+
const locale = row.locale.toLowerCase();
|
|
85
|
+
let surfaces = byLocale.get(locale);
|
|
86
|
+
if (!surfaces) {
|
|
87
|
+
surfaces = new Map();
|
|
88
|
+
byLocale.set(locale, surfaces);
|
|
89
|
+
}
|
|
90
|
+
let keys = surfaces.get(row.surface);
|
|
91
|
+
if (!keys) {
|
|
92
|
+
keys = new Map();
|
|
93
|
+
surfaces.set(row.surface, keys);
|
|
94
|
+
}
|
|
95
|
+
keys.set(row.messageKey, row.text);
|
|
96
|
+
}
|
|
97
|
+
const localePreference = [appLocale.toLowerCase(), defaultLocale.toLowerCase()];
|
|
98
|
+
return {
|
|
99
|
+
resolve(messageKey, surfaces = DEFAULT_LABEL_SURFACES) {
|
|
100
|
+
for (const locale of localePreference) {
|
|
101
|
+
const surfaceMap = byLocale.get(locale);
|
|
102
|
+
if (!surfaceMap)
|
|
103
|
+
continue;
|
|
104
|
+
for (const surface of surfaces) {
|
|
105
|
+
const text = surfaceMap.get(surface)?.get(messageKey);
|
|
106
|
+
if (text !== undefined)
|
|
107
|
+
return text;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createHostLabelResolver, resolveAppLocale, resolveTextDirection, } from "./locale-resolution.js";
|
|
3
|
+
describe("resolveAppLocale", () => {
|
|
4
|
+
const declaration = { defaultLocale: "en", supportedLocales: ["en", "pt", "pt-BR", "ar"] };
|
|
5
|
+
it("prefers an exact match", () => {
|
|
6
|
+
expect(resolveAppLocale("pt-BR", declaration).appLocale).toBe("pt-BR");
|
|
7
|
+
});
|
|
8
|
+
it("falls back to a language match when the exact tag is absent", () => {
|
|
9
|
+
expect(resolveAppLocale("pt-PT", declaration).appLocale).toBe("pt");
|
|
10
|
+
});
|
|
11
|
+
it("is case-insensitive on the requested tag", () => {
|
|
12
|
+
expect(resolveAppLocale("PT-br", declaration).appLocale).toBe("pt-BR");
|
|
13
|
+
});
|
|
14
|
+
it("falls back to the declared default when nothing matches", () => {
|
|
15
|
+
expect(resolveAppLocale("de-DE", declaration).appLocale).toBe("en");
|
|
16
|
+
});
|
|
17
|
+
it("resolves text direction from the resolved locale", () => {
|
|
18
|
+
expect(resolveAppLocale("ar", declaration).direction).toBe("rtl");
|
|
19
|
+
expect(resolveAppLocale("en", declaration).direction).toBe("ltr");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
describe("resolveTextDirection", () => {
|
|
23
|
+
it("marks RTL languages", () => {
|
|
24
|
+
expect(resolveTextDirection("he-IL")).toBe("rtl");
|
|
25
|
+
expect(resolveTextDirection("fa")).toBe("rtl");
|
|
26
|
+
expect(resolveTextDirection("en-US")).toBe("ltr");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
describe("createHostLabelResolver", () => {
|
|
30
|
+
const rows = [
|
|
31
|
+
{ locale: "en", surface: "extension", messageKey: "title", text: "Reports" },
|
|
32
|
+
{ locale: "en", surface: "navigation", messageKey: "nav", text: "Reports" },
|
|
33
|
+
{ locale: "pt", surface: "extension", messageKey: "title", text: "Relatórios" },
|
|
34
|
+
];
|
|
35
|
+
it("resolves in the app locale first", () => {
|
|
36
|
+
const resolver = createHostLabelResolver(rows, "pt", "en");
|
|
37
|
+
expect(resolver.resolve("title")).toBe("Relatórios");
|
|
38
|
+
});
|
|
39
|
+
it("falls back to the default locale deterministically", () => {
|
|
40
|
+
const resolver = createHostLabelResolver(rows, "pt", "en");
|
|
41
|
+
// "nav" is missing in pt; falls back to en.
|
|
42
|
+
expect(resolver.resolve("nav", ["navigation"])).toBe("Reports");
|
|
43
|
+
});
|
|
44
|
+
it("returns null when no surface carries the key", () => {
|
|
45
|
+
const resolver = createHostLabelResolver(rows, "en", "en");
|
|
46
|
+
expect(resolver.resolve("missing")).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
});
|
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
|
};
|
|
@@ -12,6 +19,24 @@ export interface AppsAdminRouteOptions extends AppsServiceOptions {
|
|
|
12
19
|
accessCatalog: AccessCatalog;
|
|
13
20
|
deploymentId: string;
|
|
14
21
|
};
|
|
22
|
+
/**
|
|
23
|
+
* Enables the iframe session-token broker. Requires {@link oauth} for the
|
|
24
|
+
* deployment audience and the actor-token-exchange primitive. `secret` is the
|
|
25
|
+
* root secret the signing key is HKDF-derived from.
|
|
26
|
+
*/
|
|
27
|
+
sessionToken?: {
|
|
28
|
+
secret: string;
|
|
29
|
+
ttlSeconds?: number;
|
|
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;
|
|
15
40
|
}
|
|
16
41
|
export declare function createAppsAdminRoutes(options?: AppsAdminRouteOptions): Hono<Env, import("hono/types").BlankSchema, "/">;
|
|
17
42
|
export {};
|