@voyant-travel/apps 0.3.0 → 0.5.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 +15 -0
- package/dist/api-runtime.js +15 -1
- package/dist/app-api-contracts.d.ts +51 -0
- package/dist/app-api-contracts.js +50 -0
- package/dist/app-api-routes.d.ts +28 -0
- package/dist/app-api-routes.js +141 -0
- package/dist/app-api-service.d.ts +1263 -0
- package/dist/app-api-service.js +244 -0
- package/dist/app-api-service.test.d.ts +1 -0
- package/dist/app-api-service.test.js +109 -0
- package/dist/consent.js +16 -1
- package/dist/consent.test.js +24 -0
- package/dist/contracts.d.ts +31 -3
- package/dist/contracts.js +25 -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 +8 -1
- package/dist/index.js +8 -1
- package/dist/installation-service.d.ts +1 -1
- 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 +9 -0
- package/dist/routes.js +43 -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 +87 -1
- package/migrations/20260717150000_app_session_tokens.sql +18 -0
- package/migrations/meta/_journal.json +8 -1
- package/package.json +42 -7
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installation-backed admin extension resolution (RFC §6, Phase 3).
|
|
3
|
+
*
|
|
4
|
+
* Replaces the static self-hosted descriptor list as the source of activated
|
|
5
|
+
* admin extensions: descriptors come from `app_extension_installations` for
|
|
6
|
+
* `active` installations only, so pausing, degrading, or uninstalling an app
|
|
7
|
+
* immediately drops its pages and slots. Slot extensions are filtered by
|
|
8
|
+
* release-pinned `extensionApi` compatibility, and host-rendered labels (nav
|
|
9
|
+
* entries, extension titles) plus the resolved app locale + text direction come
|
|
10
|
+
* from the installed release's declared locales and `app_release_localizations`.
|
|
11
|
+
*
|
|
12
|
+
* The returned descriptors are the exact shape the admin host consumes; static
|
|
13
|
+
* deployment-graph descriptors remain supported alongside these.
|
|
14
|
+
*/
|
|
15
|
+
import { ADMIN_UI_EXTENSION_API_VERSION, isUiExtensionCompatible, } from "@voyant-travel/admin-extension-sdk";
|
|
16
|
+
import { and, eq, inArray } from "drizzle-orm";
|
|
17
|
+
import { createHostLabelResolver, resolveAppLocale, } from "./locale-resolution.js";
|
|
18
|
+
import { appExtensionInstallations, appInstallations, appReleaseLocalizations, appReleases, } from "./schema.js";
|
|
19
|
+
export async function resolveInstalledExtensions(db, input) {
|
|
20
|
+
const rows = await db
|
|
21
|
+
.select({
|
|
22
|
+
installationId: appInstallations.id,
|
|
23
|
+
appId: appInstallations.appId,
|
|
24
|
+
extensionKey: appExtensionInstallations.extensionKey,
|
|
25
|
+
descriptor: appExtensionInstallations.descriptor,
|
|
26
|
+
releaseId: appExtensionInstallations.releaseId,
|
|
27
|
+
defaultLocale: appReleases.defaultLocale,
|
|
28
|
+
supportedLocales: appReleases.supportedLocales,
|
|
29
|
+
})
|
|
30
|
+
.from(appExtensionInstallations)
|
|
31
|
+
.innerJoin(appInstallations, eq(appExtensionInstallations.installationId, appInstallations.id))
|
|
32
|
+
.innerJoin(appReleases, eq(appExtensionInstallations.releaseId, appReleases.id))
|
|
33
|
+
.where(and(eq(appInstallations.deploymentId, input.deploymentId),
|
|
34
|
+
// Active installations only: paused/degraded/uninstalled apps unmount.
|
|
35
|
+
eq(appInstallations.status, "active"), eq(appExtensionInstallations.status, "active")));
|
|
36
|
+
const localizationsByRelease = await loadLocalizations(db, rows.map((row) => row.releaseId));
|
|
37
|
+
return assembleInstalledExtensions(rows, localizationsByRelease, {
|
|
38
|
+
activeLocale: input.activeLocale,
|
|
39
|
+
extensionApiVersion: input.extensionApiVersion,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Pure assembly of resolved descriptors from store rows and localization data.
|
|
44
|
+
* Applies compatibility filtering, locale resolution, and host-label lookup.
|
|
45
|
+
* The `rows` are assumed to already be scoped to active installations/extensions.
|
|
46
|
+
*/
|
|
47
|
+
export function assembleInstalledExtensions(rows, localizationsByRelease, options) {
|
|
48
|
+
const version = options.extensionApiVersion ?? ADMIN_UI_EXTENSION_API_VERSION;
|
|
49
|
+
const slots = [];
|
|
50
|
+
const pages = [];
|
|
51
|
+
for (const row of rows) {
|
|
52
|
+
const declaration = {
|
|
53
|
+
defaultLocale: row.defaultLocale,
|
|
54
|
+
supportedLocales: row.supportedLocales,
|
|
55
|
+
};
|
|
56
|
+
const locale = resolveAppLocale(options.activeLocale, declaration);
|
|
57
|
+
const labels = createHostLabelResolver(localizationsByRelease.get(row.releaseId) ?? [], locale.appLocale, declaration.defaultLocale);
|
|
58
|
+
if (row.extensionKey.startsWith("page:")) {
|
|
59
|
+
const page = parsePageDescriptor(row.descriptor);
|
|
60
|
+
if (!page)
|
|
61
|
+
continue;
|
|
62
|
+
const title = labels.resolve(page.titleKey, ["extension", "navigation"]) ?? page.key;
|
|
63
|
+
const navLabel = labels.resolve(page.titleKey, ["navigation", "extension"]) ?? title;
|
|
64
|
+
pages.push({
|
|
65
|
+
key: `${row.installationId}:${page.key}`,
|
|
66
|
+
installationId: row.installationId,
|
|
67
|
+
appId: row.appId,
|
|
68
|
+
path: page.path,
|
|
69
|
+
entryUrl: page.entryUrl,
|
|
70
|
+
title,
|
|
71
|
+
navLabel,
|
|
72
|
+
appLocale: locale.appLocale,
|
|
73
|
+
direction: locale.direction,
|
|
74
|
+
});
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (row.extensionKey.startsWith("slot:")) {
|
|
78
|
+
const slot = parseSlotDescriptor(row.descriptor);
|
|
79
|
+
if (!slot)
|
|
80
|
+
continue;
|
|
81
|
+
// Release-pinned compatibility filter: an incompatible extension is not
|
|
82
|
+
// mounted (the host's render-time check is defense-in-depth).
|
|
83
|
+
if (!isUiExtensionCompatible(slot.extensionApi, version))
|
|
84
|
+
continue;
|
|
85
|
+
const displayName = labels.resolve(slot.titleKey, ["extension", "navigation"]) ?? slot.key;
|
|
86
|
+
slots.push({
|
|
87
|
+
descriptor: {
|
|
88
|
+
key: `${row.installationId}:${slot.key}`,
|
|
89
|
+
version: slot.version,
|
|
90
|
+
displayName,
|
|
91
|
+
extensionApi: slot.extensionApi,
|
|
92
|
+
entryUrl: slot.entryUrl,
|
|
93
|
+
slots: slot.slots,
|
|
94
|
+
...(slot.config ? { config: slot.config } : {}),
|
|
95
|
+
},
|
|
96
|
+
installationId: row.installationId,
|
|
97
|
+
appId: row.appId,
|
|
98
|
+
appLocale: locale.appLocale,
|
|
99
|
+
direction: locale.direction,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { slots, pages };
|
|
104
|
+
}
|
|
105
|
+
async function loadLocalizations(db, releaseIds) {
|
|
106
|
+
const unique = [...new Set(releaseIds)];
|
|
107
|
+
const grouped = new Map();
|
|
108
|
+
if (unique.length === 0)
|
|
109
|
+
return grouped;
|
|
110
|
+
const rows = await db
|
|
111
|
+
.select({
|
|
112
|
+
releaseId: appReleaseLocalizations.releaseId,
|
|
113
|
+
locale: appReleaseLocalizations.locale,
|
|
114
|
+
surface: appReleaseLocalizations.surface,
|
|
115
|
+
messageKey: appReleaseLocalizations.messageKey,
|
|
116
|
+
text: appReleaseLocalizations.text,
|
|
117
|
+
})
|
|
118
|
+
.from(appReleaseLocalizations)
|
|
119
|
+
.where(inArray(appReleaseLocalizations.releaseId, unique));
|
|
120
|
+
for (const row of rows) {
|
|
121
|
+
const list = grouped.get(row.releaseId) ?? [];
|
|
122
|
+
list.push({
|
|
123
|
+
locale: row.locale,
|
|
124
|
+
surface: row.surface,
|
|
125
|
+
messageKey: row.messageKey,
|
|
126
|
+
text: row.text,
|
|
127
|
+
});
|
|
128
|
+
grouped.set(row.releaseId, list);
|
|
129
|
+
}
|
|
130
|
+
return grouped;
|
|
131
|
+
}
|
|
132
|
+
function parsePageDescriptor(value) {
|
|
133
|
+
const key = stringField(value.key);
|
|
134
|
+
const titleKey = stringField(value.titleKey);
|
|
135
|
+
const path = stringField(value.path);
|
|
136
|
+
const entryUrl = stringField(value.entryUrl);
|
|
137
|
+
if (!key || !titleKey || !path || !entryUrl)
|
|
138
|
+
return null;
|
|
139
|
+
return { key, titleKey, path, entryUrl };
|
|
140
|
+
}
|
|
141
|
+
function parseSlotDescriptor(value) {
|
|
142
|
+
const key = stringField(value.key);
|
|
143
|
+
const titleKey = stringField(value.titleKey);
|
|
144
|
+
const version = stringField(value.version);
|
|
145
|
+
const extensionApi = stringField(value.extensionApi);
|
|
146
|
+
const entryUrl = stringField(value.entryUrl);
|
|
147
|
+
const slots = Array.isArray(value.slots)
|
|
148
|
+
? value.slots.filter((slot) => typeof slot === "string")
|
|
149
|
+
: [];
|
|
150
|
+
if (!key || !titleKey || !version || !extensionApi || !entryUrl || slots.length === 0)
|
|
151
|
+
return null;
|
|
152
|
+
const config = value.config && typeof value.config === "object" && !Array.isArray(value.config)
|
|
153
|
+
? value.config
|
|
154
|
+
: undefined;
|
|
155
|
+
return { key, titleKey, version, extensionApi, entryUrl, slots, config };
|
|
156
|
+
}
|
|
157
|
+
function stringField(value) {
|
|
158
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
159
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { assembleInstalledExtensions } from "./extension-resolution.js";
|
|
3
|
+
function slotRow(overrides = {}) {
|
|
4
|
+
return {
|
|
5
|
+
installationId: "apin_1",
|
|
6
|
+
appId: "app_1",
|
|
7
|
+
extensionKey: "slot:reports",
|
|
8
|
+
releaseId: "aprl_1",
|
|
9
|
+
defaultLocale: "en",
|
|
10
|
+
supportedLocales: ["en", "pt"],
|
|
11
|
+
descriptor: {
|
|
12
|
+
key: "reports",
|
|
13
|
+
titleKey: "reports.title",
|
|
14
|
+
version: "1.0.0",
|
|
15
|
+
extensionApi: "^1",
|
|
16
|
+
entryUrl: "https://app.example.com/reports",
|
|
17
|
+
slots: ["dashboard.header"],
|
|
18
|
+
},
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function pageRow(overrides = {}) {
|
|
23
|
+
return {
|
|
24
|
+
installationId: "apin_1",
|
|
25
|
+
appId: "app_1",
|
|
26
|
+
extensionKey: "page:settings",
|
|
27
|
+
releaseId: "aprl_1",
|
|
28
|
+
defaultLocale: "en",
|
|
29
|
+
supportedLocales: ["en", "pt"],
|
|
30
|
+
descriptor: {
|
|
31
|
+
key: "settings",
|
|
32
|
+
titleKey: "settings.title",
|
|
33
|
+
path: "/settings",
|
|
34
|
+
entryUrl: "https://app.example.com/settings",
|
|
35
|
+
},
|
|
36
|
+
...overrides,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const localizations = new Map([
|
|
40
|
+
[
|
|
41
|
+
"aprl_1",
|
|
42
|
+
[
|
|
43
|
+
{ locale: "en", surface: "extension", messageKey: "reports.title", text: "Reports" },
|
|
44
|
+
{ locale: "pt", surface: "extension", messageKey: "reports.title", text: "Relatórios" },
|
|
45
|
+
{ locale: "en", surface: "navigation", messageKey: "settings.title", text: "Settings" },
|
|
46
|
+
{ locale: "en", surface: "extension", messageKey: "settings.title", text: "App Settings" },
|
|
47
|
+
],
|
|
48
|
+
],
|
|
49
|
+
]);
|
|
50
|
+
describe("assembleInstalledExtensions", () => {
|
|
51
|
+
it("resolves slot descriptors with host labels and locale/direction", () => {
|
|
52
|
+
const result = assembleInstalledExtensions([slotRow()], localizations, { activeLocale: "pt" });
|
|
53
|
+
expect(result.slots).toHaveLength(1);
|
|
54
|
+
const slot = result.slots[0];
|
|
55
|
+
expect(slot?.descriptor.key).toBe("apin_1:reports");
|
|
56
|
+
expect(slot?.descriptor.displayName).toBe("Relatórios");
|
|
57
|
+
expect(slot?.descriptor.slots).toEqual(["dashboard.header"]);
|
|
58
|
+
expect(slot?.appLocale).toBe("pt");
|
|
59
|
+
expect(slot?.direction).toBe("ltr");
|
|
60
|
+
});
|
|
61
|
+
it("resolves full-page descriptors with title and nav label", () => {
|
|
62
|
+
const result = assembleInstalledExtensions([pageRow()], localizations, { activeLocale: "en" });
|
|
63
|
+
expect(result.pages).toHaveLength(1);
|
|
64
|
+
const page = result.pages[0];
|
|
65
|
+
expect(page?.key).toBe("apin_1:settings");
|
|
66
|
+
expect(page?.path).toBe("/settings");
|
|
67
|
+
expect(page?.title).toBe("App Settings");
|
|
68
|
+
expect(page?.navLabel).toBe("Settings");
|
|
69
|
+
});
|
|
70
|
+
it("filters out extensionApi-incompatible slot extensions", () => {
|
|
71
|
+
const incompatible = slotRow({
|
|
72
|
+
descriptor: {
|
|
73
|
+
key: "legacy",
|
|
74
|
+
titleKey: "reports.title",
|
|
75
|
+
version: "1.0.0",
|
|
76
|
+
extensionApi: "^0.9",
|
|
77
|
+
entryUrl: "https://app.example.com/legacy",
|
|
78
|
+
slots: ["dashboard.header"],
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
const result = assembleInstalledExtensions([incompatible], localizations, {
|
|
82
|
+
activeLocale: "en",
|
|
83
|
+
extensionApiVersion: "1.1.0",
|
|
84
|
+
});
|
|
85
|
+
expect(result.slots).toHaveLength(0);
|
|
86
|
+
});
|
|
87
|
+
it("falls back to the descriptor key when no host label exists", () => {
|
|
88
|
+
const result = assembleInstalledExtensions([slotRow({ releaseId: "aprl_none" })], new Map(), {
|
|
89
|
+
activeLocale: "en",
|
|
90
|
+
});
|
|
91
|
+
expect(result.slots[0]?.descriptor.displayName).toBe("reports");
|
|
92
|
+
});
|
|
93
|
+
it("falls back to the default locale for an unsupported active locale", () => {
|
|
94
|
+
const result = assembleInstalledExtensions([slotRow()], localizations, { activeLocale: "de" });
|
|
95
|
+
expect(result.slots[0]?.appLocale).toBe("en");
|
|
96
|
+
expect(result.slots[0]?.descriptor.displayName).toBe("Reports");
|
|
97
|
+
});
|
|
98
|
+
it("skips malformed descriptors (fail-soft)", () => {
|
|
99
|
+
const malformed = slotRow({ descriptor: { key: "broken" } });
|
|
100
|
+
const result = assembleInstalledExtensions([malformed], localizations, { activeLocale: "en" });
|
|
101
|
+
expect(result.slots).toHaveLength(0);
|
|
102
|
+
expect(result.pages).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
it("gives each installation a unique descriptor key", () => {
|
|
105
|
+
const result = assembleInstalledExtensions([slotRow(), slotRow({ installationId: "apin_2" })], localizations, { activeLocale: "en" });
|
|
106
|
+
expect(result.slots.map((slot) => slot.descriptor.key)).toEqual([
|
|
107
|
+
"apin_1:reports",
|
|
108
|
+
"apin_2:reports",
|
|
109
|
+
]);
|
|
110
|
+
});
|
|
111
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
export * from "./access-boundary.js";
|
|
2
|
+
export * from "./app-api-contracts.js";
|
|
3
|
+
export * from "./app-api-routes.js";
|
|
4
|
+
export * from "./app-api-service.js";
|
|
2
5
|
export * from "./compiler.js";
|
|
3
6
|
export * from "./consent.js";
|
|
4
7
|
export * from "./contracts.js";
|
|
8
|
+
export * from "./extension-resolution.js";
|
|
5
9
|
export * from "./ingestion.js";
|
|
6
10
|
export * from "./installation-service.js";
|
|
11
|
+
export * from "./locale-resolution.js";
|
|
7
12
|
export * from "./oauth-crypto.js";
|
|
8
13
|
export * from "./oauth-service.js";
|
|
9
14
|
export { createAppsAdminRoutes } from "./routes.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";
|
|
15
|
+
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, appSessionTokens, apps, appWebhookSubscriptionStatusEnum, appWebhookSubscriptions, } from "./schema.js";
|
|
11
16
|
export { createAppsService } from "./service.js";
|
|
17
|
+
export * from "./session-token.js";
|
|
18
|
+
export * from "./session-token-service.js";
|
|
12
19
|
export type { AppWebhookDeliveryOptions } from "./webhook-delivery.js";
|
|
13
20
|
export { createAppWebhookDeliveryStore, createAppWebhookEventQueue, enqueueAppWebhookEvent, listAppWebhookHealth, replayAppWebhookDelivery, } from "./webhook-delivery.js";
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
export * from "./access-boundary.js";
|
|
2
|
+
export * from "./app-api-contracts.js";
|
|
3
|
+
export * from "./app-api-routes.js";
|
|
4
|
+
export * from "./app-api-service.js";
|
|
2
5
|
export * from "./compiler.js";
|
|
3
6
|
export * from "./consent.js";
|
|
4
7
|
export * from "./contracts.js";
|
|
8
|
+
export * from "./extension-resolution.js";
|
|
5
9
|
export * from "./ingestion.js";
|
|
6
10
|
export * from "./installation-service.js";
|
|
11
|
+
export * from "./locale-resolution.js";
|
|
7
12
|
export * from "./oauth-crypto.js";
|
|
8
13
|
export * from "./oauth-service.js";
|
|
9
14
|
export { createAppsAdminRoutes } from "./routes.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";
|
|
15
|
+
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, appSessionTokens, apps, appWebhookSubscriptionStatusEnum, appWebhookSubscriptions, } from "./schema.js";
|
|
11
16
|
export { createAppsService } from "./service.js";
|
|
17
|
+
export * from "./session-token.js";
|
|
18
|
+
export * from "./session-token-service.js";
|
|
12
19
|
export { createAppWebhookDeliveryStore, createAppWebhookEventQueue, enqueueAppWebhookEvent, listAppWebhookHealth, replayAppWebhookDelivery, } from "./webhook-delivery.js";
|
|
@@ -100,7 +100,7 @@ export declare function createAppInstallationService(options?: AppInstallationSe
|
|
|
100
100
|
uninstalledAt: Date | null;
|
|
101
101
|
purgedAt: Date | null;
|
|
102
102
|
};
|
|
103
|
-
outcome: "
|
|
103
|
+
outcome: "reinstalled" | "created";
|
|
104
104
|
}>;
|
|
105
105
|
upgrade: (db: PostgresJsDatabase, input: UpgradeAppInput) => Promise<{
|
|
106
106
|
installation: {
|
|
@@ -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
|
@@ -12,6 +12,15 @@ export interface AppsAdminRouteOptions extends AppsServiceOptions {
|
|
|
12
12
|
accessCatalog: AccessCatalog;
|
|
13
13
|
deploymentId: string;
|
|
14
14
|
};
|
|
15
|
+
/**
|
|
16
|
+
* Enables the iframe session-token broker. Requires {@link oauth} for the
|
|
17
|
+
* deployment audience and the actor-token-exchange primitive. `secret` is the
|
|
18
|
+
* root secret the signing key is HKDF-derived from.
|
|
19
|
+
*/
|
|
20
|
+
sessionToken?: {
|
|
21
|
+
secret: string;
|
|
22
|
+
ttlSeconds?: number;
|
|
23
|
+
};
|
|
15
24
|
}
|
|
16
25
|
export declare function createAppsAdminRoutes(options?: AppsAdminRouteOptions): Hono<Env, import("hono/types").BlankSchema, "/">;
|
|
17
26
|
export {};
|
package/dist/routes.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { parseJsonBody, parseQuery, RequestValidationError } from "@voyant-travel/hono";
|
|
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, appWebhookReplaySchema, createCustomAppRegistrationSchema, releaseManifestFetchSchema, releaseManifestUploadSchema, } from "./contracts.js";
|
|
4
|
+
import { appCredentialRevocationSchema, appListQuerySchema, appOAuthAuthorizeQuerySchema, appOAuthTokenSchema, appSessionTokenExchangeSchema, appSessionTokenIssueSchema, appWebhookReplaySchema, createCustomAppRegistrationSchema, releaseManifestFetchSchema, releaseManifestUploadSchema, } from "./contracts.js";
|
|
5
5
|
import { createAppOAuthService } from "./oauth-service.js";
|
|
6
6
|
import { createAppsService } from "./service.js";
|
|
7
|
+
import { createAppSessionTokenService } from "./session-token-service.js";
|
|
7
8
|
import { listAppWebhookHealth, replayAppWebhookDelivery } from "./webhook-delivery.js";
|
|
8
9
|
const appIdParamSchema = z.object({ appId: z.string().min(1) });
|
|
9
10
|
const installationIdParamSchema = z.object({ installationId: z.string().min(1) });
|
|
@@ -11,6 +12,14 @@ export function createAppsAdminRoutes(options = {}) {
|
|
|
11
12
|
const routes = new Hono();
|
|
12
13
|
const service = createAppsService(options);
|
|
13
14
|
const oauth = options.oauth ? createAppOAuthService(options.oauth) : null;
|
|
15
|
+
const sessionTokens = oauth && options.oauth && options.sessionToken
|
|
16
|
+
? createAppSessionTokenService({
|
|
17
|
+
secret: options.sessionToken.secret,
|
|
18
|
+
ttlSeconds: options.sessionToken.ttlSeconds,
|
|
19
|
+
deploymentId: options.oauth.deploymentId,
|
|
20
|
+
oauth,
|
|
21
|
+
})
|
|
22
|
+
: null;
|
|
14
23
|
routes.get("/", async (c) => {
|
|
15
24
|
const query = parseQuery(c, appListQuerySchema);
|
|
16
25
|
return c.json(await service.list(c.get("db"), query), 200);
|
|
@@ -81,6 +90,38 @@ export function createAppsAdminRoutes(options = {}) {
|
|
|
81
90
|
const result = await oauth.revokeInstallationCredentials(c.get("db"), body.installationId, body.actorId);
|
|
82
91
|
return c.json(result, 200);
|
|
83
92
|
});
|
|
93
|
+
// Staff-authenticated: the admin host requests a short-lived session token for
|
|
94
|
+
// the current viewer + entity/slot context. The viewer is taken from the
|
|
95
|
+
// authenticated session, never from the frame.
|
|
96
|
+
routes.post("/installations/:installationId/session-token", async (c) => {
|
|
97
|
+
if (!sessionTokens)
|
|
98
|
+
return c.json({ error: "App session tokens are not configured" }, 501);
|
|
99
|
+
const { installationId } = parseInstallationParams(c.req.param());
|
|
100
|
+
const viewerId = requireUserId(c);
|
|
101
|
+
const body = await parseJsonBody(c, appSessionTokenIssueSchema);
|
|
102
|
+
const issued = await sessionTokens.issue(c.get("db"), {
|
|
103
|
+
installationId,
|
|
104
|
+
viewerId,
|
|
105
|
+
entity: body.entity ?? null,
|
|
106
|
+
slot: body.slot ?? null,
|
|
107
|
+
});
|
|
108
|
+
return c.json({ data: issued }, 201);
|
|
109
|
+
});
|
|
110
|
+
// App-backend-facing: exchange a presented session token for online actor
|
|
111
|
+
// access. Client-authenticated; bounded by viewer ∩ app grants.
|
|
112
|
+
routes.post("/oauth/session-token/exchange", async (c) => {
|
|
113
|
+
if (!sessionTokens)
|
|
114
|
+
return c.json({ error: "App session tokens are not configured" }, 501);
|
|
115
|
+
const body = await parseJsonBody(c, appSessionTokenExchangeSchema);
|
|
116
|
+
const token = await sessionTokens.exchange(c.get("db"), {
|
|
117
|
+
token: body.session_token,
|
|
118
|
+
clientId: body.client_id,
|
|
119
|
+
clientSecret: body.client_secret,
|
|
120
|
+
viewerScopes: body.viewer_scopes,
|
|
121
|
+
contextualScopes: body.contextual_scopes,
|
|
122
|
+
});
|
|
123
|
+
return c.json(token, 200);
|
|
124
|
+
});
|
|
84
125
|
routes.get("/:appId", async (c) => {
|
|
85
126
|
const { appId } = parseParams(c.req.param());
|
|
86
127
|
const app = await service.get(c.get("db"), appId);
|