@lamalibre/create-lamaste 4.6.0 → 4.7.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.
Files changed (149) hide show
  1. package/package.json +2 -2
  2. package/scripts/bundle-vendor.js +20 -3
  3. package/scripts/lamaste-priv +22 -3
  4. package/scripts/pin-release.js +98 -0
  5. package/src/index.js +8 -1
  6. package/src/lib/env.js +19 -0
  7. package/src/lib/service-config.js +7 -0
  8. package/src/tasks/redeploy.js +8 -0
  9. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  10. package/vendor/docs/00-introduction/quickstart.md +5 -5
  11. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  12. package/vendor/docs/01-concepts/authentication.md +6 -2
  13. package/vendor/docs/01-concepts/authorization.md +4 -0
  14. package/vendor/docs/01-concepts/certificates.md +3 -3
  15. package/vendor/docs/01-concepts/dns-and-domains.md +9 -0
  16. package/vendor/docs/01-concepts/mtls.md +2 -2
  17. package/vendor/docs/01-concepts/security-model.md +28 -3
  18. package/vendor/docs/01-concepts/tickets.md +2 -2
  19. package/vendor/docs/01-concepts/tunneling.md +4 -4
  20. package/vendor/docs/02-guides/agent-setup.md +5 -5
  21. package/vendor/docs/02-guides/certificate-management.md +2 -2
  22. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  23. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  24. package/vendor/docs/02-guides/installation.md +3 -3
  25. package/vendor/docs/02-guides/managing-users.md +5 -11
  26. package/vendor/docs/02-guides/onboarding.md +2 -2
  27. package/vendor/docs/02-guides/second-domain.md +82 -0
  28. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  29. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  30. package/vendor/docs/03-architecture/installer.md +4 -4
  31. package/vendor/docs/03-architecture/management-flow.md +1 -1
  32. package/vendor/docs/03-architecture/nginx-configuration.md +15 -5
  33. package/vendor/docs/03-architecture/overview.md +5 -5
  34. package/vendor/docs/03-architecture/panel-client.md +6 -6
  35. package/vendor/docs/03-architecture/panel-server.md +2 -2
  36. package/vendor/docs/03-architecture/state-management.md +3 -3
  37. package/vendor/docs/03-architecture/system-overview.md +1 -1
  38. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +17 -13
  39. package/vendor/docs/04-api-reference/app-host.md +3 -3
  40. package/vendor/docs/04-api-reference/domains.md +192 -0
  41. package/vendor/docs/04-api-reference/gatekeeper.md +35 -7
  42. package/vendor/docs/04-api-reference/lifecycle-hooks.md +4 -4
  43. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  44. package/vendor/docs/04-api-reference/overview.md +9 -0
  45. package/vendor/docs/04-api-reference/registry-and-oidc.md +26 -13
  46. package/vendor/docs/04-api-reference/sites.md +5 -0
  47. package/vendor/docs/04-api-reference/tunnels.md +15 -10
  48. package/vendor/docs/04-api-reference/users.md +41 -29
  49. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  50. package/vendor/docs/05-operations/uninstalling.md +1 -1
  51. package/vendor/docs/05-operations/upgrades.md +6 -6
  52. package/vendor/docs/06-reference/config-files.md +1 -1
  53. package/vendor/docs/06-reference/glossary.md +3 -3
  54. package/vendor/docs/06-reference/installer-flags.md +13 -13
  55. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  56. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  57. package/vendor/docs/_index.json +105 -19
  58. package/vendor/docs/external-members-design.md +816 -0
  59. package/vendor/docs/gatekeeper-design.md +8 -8
  60. package/vendor/docs/identity-directory-design.md +3 -3
  61. package/vendor/docs/multi-domain-relay-design.md +836 -0
  62. package/vendor/docs/package.json +1 -1
  63. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  64. package/vendor/gatekeeper/dist/cli/index.js +0 -0
  65. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
  66. package/vendor/gatekeeper/dist/lib/grants.js +20 -13
  67. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
  68. package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0004_unconsume_access_grants.sql +8 -0
  69. package/vendor/gatekeeper/dist/lib/secret.d.ts +6 -0
  70. package/vendor/gatekeeper/dist/lib/secret.d.ts.map +1 -0
  71. package/vendor/gatekeeper/dist/lib/secret.js +12 -0
  72. package/vendor/gatekeeper/dist/lib/secret.js.map +1 -0
  73. package/vendor/gatekeeper/dist/lib/templates.d.ts +20 -0
  74. package/vendor/gatekeeper/dist/lib/templates.d.ts.map +1 -1
  75. package/vendor/gatekeeper/dist/lib/templates.js +80 -14
  76. package/vendor/gatekeeper/dist/lib/templates.js.map +1 -1
  77. package/vendor/gatekeeper/dist/server/index.d.ts +5 -1
  78. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  79. package/vendor/gatekeeper/dist/server/index.js +28 -2
  80. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  81. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  82. package/vendor/gatekeeper/dist/server/routes/authz.js +225 -36
  83. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  84. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
  85. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +5 -2
  86. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
  87. package/vendor/gatekeeper/package.json +2 -2
  88. package/vendor/server/bin/lamaste-server.js +7 -0
  89. package/vendor/server/package.json +2 -2
  90. package/vendor/server/src/commands/domains.js +214 -0
  91. package/vendor/server/src/commands/plugin-identity.js +2 -16
  92. package/vendor/server/src/commands/plugins.js +112 -8
  93. package/vendor/server/src/commands/registry-credential.js +10 -3
  94. package/vendor/server/src/commands/sites.js +3 -0
  95. package/vendor/server/src/commands/status.js +19 -0
  96. package/vendor/server/src/commands/tunnels.js +4 -1
  97. package/vendor/server/src/commands/users.js +8 -19
  98. package/vendor/server/src/domains.js +92 -0
  99. package/vendor/server/src/fail.js +21 -0
  100. package/vendor/server/src/oidc-client-grants.js +63 -0
  101. package/vendor/server/src/panel-api.js +15 -0
  102. package/vendor/server-ui/dist/assets/index-B8Tv_ocF.js +84 -0
  103. package/vendor/server-ui/dist/assets/{index-BZwQ_aQc.css → index-Cb-ADsbB.css} +1 -1
  104. package/vendor/server-ui/dist/index.html +2 -2
  105. package/vendor/serverd/package.json +2 -2
  106. package/vendor/serverd/src/index.js +42 -16
  107. package/vendor/serverd/src/lib/app-error.js +24 -0
  108. package/vendor/serverd/src/lib/audience.js +12 -3
  109. package/vendor/serverd/src/lib/authelia.js +341 -35
  110. package/vendor/serverd/src/lib/directory.js +67 -52
  111. package/vendor/serverd/src/lib/domains.js +1792 -0
  112. package/vendor/serverd/src/lib/gatekeeper-client.js +20 -4
  113. package/vendor/serverd/src/lib/identity-sync.js +35 -0
  114. package/vendor/serverd/src/lib/migrations/state/0016_domains.sql +53 -0
  115. package/vendor/serverd/src/lib/migrations/state/0017_membership_kind.sql +15 -0
  116. package/vendor/serverd/src/lib/migrations/state/0018_domain_group_keys.sql +23 -0
  117. package/vendor/serverd/src/lib/nginx.js +318 -140
  118. package/vendor/serverd/src/lib/owned-groups.js +326 -0
  119. package/vendor/serverd/src/lib/plugin-domain.js +86 -0
  120. package/vendor/serverd/src/lib/plugin-lifecycle.js +1 -0
  121. package/vendor/serverd/src/lib/plugin-oidc.js +144 -42
  122. package/vendor/serverd/src/lib/plugins.js +45 -5
  123. package/vendor/serverd/src/lib/registry-credential.js +39 -6
  124. package/vendor/serverd/src/lib/services.js +9 -1
  125. package/vendor/serverd/src/lib/state.js +11 -0
  126. package/vendor/serverd/src/lib/tunnel-deps.js +2 -4
  127. package/vendor/serverd/src/lib/user-surface.js +29 -4
  128. package/vendor/serverd/src/lib/users-lock.js +23 -0
  129. package/vendor/serverd/src/lib/vhost-reconcile.js +149 -41
  130. package/vendor/serverd/src/lib/web-surface.js +49 -32
  131. package/vendor/serverd/src/middleware/user-access-session.js +11 -4
  132. package/vendor/serverd/src/routes/invite.js +15 -5
  133. package/vendor/serverd/src/routes/management/domains.js +154 -0
  134. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +44 -8
  135. package/vendor/serverd/src/routes/management/identity.js +21 -7
  136. package/vendor/serverd/src/routes/management/invitations.js +33 -4
  137. package/vendor/serverd/src/routes/management/plugins.js +4 -2
  138. package/vendor/serverd/src/routes/management/registry-credential.js +86 -57
  139. package/vendor/serverd/src/routes/management/sites.js +50 -11
  140. package/vendor/serverd/src/routes/management/tunnels.js +29 -5
  141. package/vendor/serverd/src/routes/management/users.js +184 -64
  142. package/vendor/serverd/src/routes/management.js +2 -0
  143. package/vendor/serverd/src/routes/onboarding/dns.js +3 -17
  144. package/vendor/serverd/src/routes/onboarding/provision.js +22 -13
  145. package/vendor/serverd/src/routes/user-access.js +13 -1
  146. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0001_groups.sql +0 -7
  147. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0002_access_grants.sql +0 -14
  148. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0003_access_request_log.sql +0 -9
  149. package/vendor/server-ui/dist/assets/index-BvEfCZYS.js +0 -84
@@ -0,0 +1,326 @@
1
+ /**
2
+ * The Authelia groups Lamaste owns, as the relay's state says they should
3
+ * be (packages/core/docs/external-members-design.md; core owned-groups.ts) —
4
+ * what lib/domains.js `syncDomainAccess` writes into users.yml:
5
+ *
6
+ * - each domain's members and external members (`lamaste:m:<key>`,
7
+ * `lamaste:x:<key>`, the key a random one of the domain's record);
8
+ * - a protected static site's listed users (`lamaste:s:<site id>`);
9
+ * - the external members granted a Gatekeeper-gated `restricted` host — a
10
+ * restricted tunnel (a user surface's too) or a restricted web surface —
11
+ * by a grant in Gatekeeper, direct or through a Lamaste group (the host's
12
+ * group, named by its hostname);
13
+ * - the external members granted a user-facing OIDC client
14
+ * (`lamaste:oidc@<id>`, a Gatekeeper grant `resourceType: 'oidc-client'`).
15
+ *
16
+ * Grants are read from Gatekeeper only while some domain has external
17
+ * members (members reach those hosts and clients through their domain's
18
+ * group). When they cannot be read — Gatekeeper, the tunnels or the plugin
19
+ * manifests — external members hold no host or client group until they
20
+ * can (fail closed): the memberships, from state.db, are rendered all the
21
+ * same.
22
+ */
23
+
24
+ import { OWNED_GROUP_PREFIX, renderOwnedGroups } from '@lamalibre/lamaste';
25
+ import { httpError } from './app-error.js';
26
+ import {
27
+ listGatekeeperGroups,
28
+ listGrantsOfType,
29
+ revokeGatekeeperGrant,
30
+ } from './gatekeeper-client.js';
31
+ import { readTunnels } from './state.js';
32
+
33
+ /** The Gatekeeper resource type of a grant on an OIDC client (`resourceId`: the client id). */
34
+ export const OIDC_CLIENT_RESOURCE = 'oidc-client';
35
+
36
+ /**
37
+ * Whether an OIDC client signs users in — a grant can name it — rather than
38
+ * a service (`client_credentials`, never mixed with a user grant).
39
+ *
40
+ * @param {{ grantTypes: readonly string[] }} client
41
+ */
42
+ export function isUserFacingOidcClient(client) {
43
+ return !client.grantTypes.includes('client_credentials');
44
+ }
45
+
46
+ /** An access mode as Gatekeeper reads it: restricted when absent or unknown. */
47
+ function accessModeOf(value) {
48
+ return value === 'public' || value === 'authenticated' ? value : 'restricted';
49
+ }
50
+
51
+ /**
52
+ * The Gatekeeper-gated restricted hosts: enabled restricted tunnels with a
53
+ * hostname (an internal plugin tunnel has none; an agent panel is mTLS,
54
+ * not Gatekeeper's; a tunnel without an access mode is restricted, as
55
+ * Gatekeeper reads it) and restricted web surfaces (plugin-owned sites),
56
+ * each with the resource its grants name. A disabled tunnel serves nobody —
57
+ * a web surface moved off a tunnel leaves it disabled on the same hostname,
58
+ * and its grants must not name anyone there.
59
+ *
60
+ * @param {ReadonlyArray<Record<string, unknown>>} tunnels
61
+ * @param {ReadonlyArray<Record<string, unknown>>} sites
62
+ * @returns {Array<{ fqdn: string, resourceType: 'tunnel' | 'site', resourceId: string }>}
63
+ */
64
+ export function restrictedHosts(tunnels, sites) {
65
+ const hosts = [];
66
+ for (const t of tunnels) {
67
+ if (
68
+ typeof t?.fqdn === 'string' &&
69
+ typeof t.id === 'string' &&
70
+ t.enabled !== false &&
71
+ t.type !== 'panel' &&
72
+ t.exposure !== 'internal' &&
73
+ accessModeOf(t.accessMode) === 'restricted'
74
+ ) {
75
+ hosts.push({ fqdn: t.fqdn.toLowerCase(), resourceType: 'tunnel', resourceId: t.id });
76
+ }
77
+ }
78
+ for (const s of sites) {
79
+ if (s?.owner && accessModeOf(s.accessMode) === 'restricted' && typeof s.fqdn === 'string') {
80
+ hosts.push({ fqdn: s.fqdn.toLowerCase(), resourceType: 'site', resourceId: s.id });
81
+ }
82
+ }
83
+ return hosts;
84
+ }
85
+
86
+ /**
87
+ * The usernames each grant of `grants` reaches — a user grant its user, a
88
+ * group grant the members of the Lamaste group — keyed by resource id.
89
+ *
90
+ * @param {ReadonlyArray<{ principalType: 'user' | 'group', principalId: string, resourceId: string }>} grants
91
+ * @param {ReadonlyMap<string, { members: readonly string[] }>} groups by name
92
+ * @returns {Map<string, Set<string>>}
93
+ */
94
+ export function grantedUsernames(grants, groups) {
95
+ const out = new Map();
96
+ for (const grant of grants) {
97
+ let set = out.get(grant.resourceId);
98
+ if (!set) out.set(grant.resourceId, (set = new Set()));
99
+ if (grant.principalType === 'user') set.add(grant.principalId);
100
+ else for (const member of groups.get(grant.principalId)?.members ?? []) set.add(member);
101
+ }
102
+ return out;
103
+ }
104
+
105
+ /**
106
+ * Every grant on tunnels, sites and OIDC clients, with the Lamaste groups
107
+ * expanded — the usernames granted each resource, by type and id — or the
108
+ * reason Gatekeeper could not say.
109
+ *
110
+ * @returns {Promise<{ ok: true, granted: Record<'tunnel' | 'site' | 'oidc-client', Map<string, Set<string>>> } | { ok: false, error: string }>}
111
+ */
112
+ export async function readGrantedResources() {
113
+ const [tunnel, site, client, groups] = await Promise.all([
114
+ listGrantsOfType('tunnel'),
115
+ listGrantsOfType('site'),
116
+ listGrantsOfType(OIDC_CLIENT_RESOURCE),
117
+ listGatekeeperGroups(),
118
+ ]);
119
+ const failed = [tunnel, site, client, groups].find((r) => !r.ok);
120
+ if (failed) return { ok: false, error: failed.error };
121
+ const byName = new Map(groups.groups.map((g) => [g.name, g]));
122
+ return {
123
+ ok: true,
124
+ granted: {
125
+ tunnel: grantedUsernames(tunnel.grants, byName),
126
+ site: grantedUsernames(site.grants, byName),
127
+ [OIDC_CLIENT_RESOURCE]: grantedUsernames(client.grants, byName),
128
+ },
129
+ };
130
+ }
131
+
132
+ /**
133
+ * The user-facing OIDC clients Authelia serves, each with its domain — the
134
+ * clients a grant can name (a `client_credentials` client signs in no one).
135
+ *
136
+ * @returns {Promise<Array<{ id: string, domain: string }>>}
137
+ */
138
+ export async function userFacingOidcClients() {
139
+ const { planOidcClients } = await import('./plugin-oidc.js');
140
+ const { served } = await planOidcClients();
141
+ return served.filter(isUserFacingOidcClient).map((c) => ({ id: c.id, domain: c.domain }));
142
+ }
143
+
144
+ /**
145
+ * The owned groups every user should hold (core `renderOwnedGroups`):
146
+ * `complete` false — with a `warning` — when the grants could not be read,
147
+ * and the hosts and OIDC clients external members are granted were then
148
+ * left out (fail closed).
149
+ *
150
+ * @param {{
151
+ * domains: ReadonlyArray<{ name: string, groupKey: string, members: readonly string[], externals: readonly string[] }>,
152
+ * sites: ReadonlyArray<Record<string, unknown>>,
153
+ * }} input `domains` the active ones with their people, `sites` sites.json
154
+ * (or the sites being saved)
155
+ * @returns {Promise<{ desired: Map<string, string[]>, complete: boolean, warning?: string }>}
156
+ */
157
+ export async function desiredOwnedGroups({ domains, sites }) {
158
+ const siteGroups = sites
159
+ .filter(
160
+ (s) =>
161
+ s?.autheliaProtected === true &&
162
+ typeof s.id === 'string' &&
163
+ typeof s.fqdn === 'string' &&
164
+ Array.isArray(s.allowedUsers) &&
165
+ s.allowedUsers.length > 0,
166
+ )
167
+ .map((s) => ({ id: s.id, fqdn: s.fqdn.toLowerCase(), users: s.allowedUsers }));
168
+
169
+ let hostGrants = [];
170
+ let clientGrants = [];
171
+ let warning;
172
+ if (domains.some((d) => d.externals.length > 0)) {
173
+ try {
174
+ const [hosts, clients, read] = await Promise.all([
175
+ readTunnels().then((tunnels) => restrictedHosts(tunnels, sites)),
176
+ userFacingOidcClients(),
177
+ readGrantedResources(),
178
+ ]);
179
+ if (!read.ok) throw new Error(`Gatekeeper: ${read.error}`);
180
+ hostGrants = hosts.map((h) => ({
181
+ fqdn: h.fqdn,
182
+ users: [...(read.granted[h.resourceType].get(h.resourceId) ?? [])],
183
+ }));
184
+ clientGrants = clients.map((c) => ({
185
+ clientId: c.id,
186
+ domain: c.domain,
187
+ users: [...(read.granted[OIDC_CLIENT_RESOURCE].get(c.id) ?? [])],
188
+ }));
189
+ } catch (err) {
190
+ hostGrants = [];
191
+ clientGrants = [];
192
+ warning = `What external members are granted could not be read (${err.message}): they hold no granted host or OIDC client until it can — the panel renders again within a minute`;
193
+ }
194
+ }
195
+
196
+ return {
197
+ desired: renderOwnedGroups({ domains, sites: siteGroups, hostGrants, clientGrants }),
198
+ complete: warning === undefined,
199
+ ...(warning ? { warning } : {}),
200
+ };
201
+ }
202
+
203
+ /** Whether an owned group names a grant (a host group, an OIDC client group) rather than a membership or a site's list. */
204
+ function isGrantGroup(group) {
205
+ return !group.startsWith(OWNED_GROUP_PREFIX) || group.startsWith(`${OWNED_GROUP_PREFIX}oidc@`);
206
+ }
207
+
208
+ /**
209
+ * The owned groups each user held as the last rendering from complete
210
+ * inputs decided (membership groups as the last rendering of any kind
211
+ * did) — what a narrowing is measured against. Null until the first
212
+ * rendering, which takes what users.yml holds then.
213
+ *
214
+ * @type {Map<string, readonly string[]> | null}
215
+ */
216
+ let baseline = null;
217
+
218
+ /** Forget the baseline (tests). */
219
+ export function resetOwnedGroupsBaselineForTests() {
220
+ baseline = null;
221
+ }
222
+
223
+ /**
224
+ * Who lost owned groups since the baseline, as `desired` decides — each
225
+ * enabled user of `held` (users.yml now) with the groups they lose — and
226
+ * the baseline moved on. When `complete` is false the grants were not read
227
+ * and `desired` holds none: grant groups are then neither counted as lost
228
+ * nor forgotten — a grant revoked meanwhile is found lost by the next
229
+ * complete rendering. A user who is gone or disabled is no narrowing to
230
+ * follow (Authelia refuses them; offboarding revokes their tokens).
231
+ *
232
+ * @param {ReadonlyMap<string, readonly string[]>} desired
233
+ * @param {boolean} complete
234
+ * @param {ReadonlyMap<string, { groups: readonly string[], active: boolean }>} held
235
+ * @returns {Array<{ username: string, lost: string[], before: readonly string[], after: string[] }>}
236
+ */
237
+ export function narrowingSince(desired, complete, held) {
238
+ if (baseline === null) {
239
+ baseline = new Map([...held].map(([u, h]) => [u, [...h.groups]]));
240
+ }
241
+ const previous = baseline;
242
+ const next = new Map();
243
+ const narrowed = [];
244
+ for (const username of new Set([...previous.keys(), ...desired.keys()])) {
245
+ const before = previous.get(username) ?? [];
246
+ const kept = complete ? [] : before.filter(isGrantGroup);
247
+ const after = [...new Set([...(desired.get(username) ?? []), ...kept])];
248
+ if (after.length > 0) next.set(username, after);
249
+ const lost = before.filter((g) => !after.includes(g));
250
+ if (lost.length > 0 && held.get(username)?.active) {
251
+ narrowed.push({ username, lost, before, after });
252
+ }
253
+ }
254
+ baseline = next;
255
+ return narrowed;
256
+ }
257
+
258
+ /**
259
+ * The user-facing OIDC clients that sign users in with the device-code
260
+ * flow, each with its domain's group key (core `reachedOidcClients`) —
261
+ * those Authelia checks against the groups a session last refreshed.
262
+ *
263
+ * @param {ReadonlyArray<{ name: string, groupKey: string }>} domains the active domains
264
+ * @returns {Promise<Array<{ id: string, groupKey: string }>>}
265
+ */
266
+ export async function deviceCodeClients(domains) {
267
+ const keys = new Map(domains.map((d) => [d.name, d.groupKey]));
268
+ const { planOidcClients } = await import('./plugin-oidc.js');
269
+ const { served } = await planOidcClients();
270
+ return served
271
+ .filter((c) => isUserFacingOidcClient(c) && c.grantTypes.includes(DEVICE_CODE_GRANT))
272
+ .filter((c) => keys.has(c.domain))
273
+ .map((c) => ({ id: c.id, groupKey: keys.get(c.domain) }));
274
+ }
275
+
276
+ /** The OAuth grant type of the device-code flow. */
277
+ export const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
278
+
279
+ /**
280
+ * Revoke every Gatekeeper grant on the OIDC clients `clientIds` — a plugin
281
+ * uninstalled takes the grants on its clients with it.
282
+ *
283
+ * @param {readonly string[]} clientIds
284
+ * @returns {Promise<{ ok: true, revoked: number } | { ok: false, error: string }>}
285
+ */
286
+ export async function revokeOidcClientGrants(clientIds) {
287
+ if (clientIds.length === 0) return { ok: true, revoked: 0 };
288
+ const list = await listGrantsOfType(OIDC_CLIENT_RESOURCE);
289
+ if (!list.ok) return { ok: false, error: list.error };
290
+ let revoked = 0;
291
+ for (const grant of list.grants.filter((g) => clientIds.includes(g.resourceId))) {
292
+ const result = await revokeGatekeeperGrant(grant.grantId);
293
+ if (!result.ok) return { ok: false, error: result.error };
294
+ revoked += 1;
295
+ }
296
+ return { ok: true, revoked };
297
+ }
298
+
299
+ /**
300
+ * A grant the Gatekeeper proxy is about to create, checked when it names an
301
+ * OIDC client: the client must be one an installed plugin declares (or the
302
+ * panel's own) and sign users in — a `client_credentials` client has no
303
+ * user to grant (400 `unknown_oidc_client` / `service_oidc_client`). Any
304
+ * other body goes on as it came.
305
+ *
306
+ * @param {unknown} body
307
+ * @returns {Promise<unknown>}
308
+ */
309
+ export async function checkOidcClientGrant(body) {
310
+ if (typeof body !== 'object' || body === null || body.resourceType !== OIDC_CLIENT_RESOURCE) {
311
+ return body;
312
+ }
313
+ const { collectPluginOidcClients } = await import('./plugin-oidc.js');
314
+ const client = (await collectPluginOidcClients()).find((c) => c.id === body.resourceId);
315
+ if (!client) {
316
+ throw httpError('No installed plugin declares this OIDC client', 400, {
317
+ code: 'unknown_oidc_client',
318
+ });
319
+ }
320
+ if (!isUserFacingOidcClient(client)) {
321
+ throw httpError('This OIDC client signs in no user: there is nothing to grant', 400, {
322
+ code: 'service_oidc_client',
323
+ });
324
+ }
325
+ return body;
326
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The domain a plugin belongs to (the multi-domain relay): its OIDC
3
+ * clients' `{domain}` and sign-in, the tunnels that may be bound as its user
4
+ * surface, its web surface's hostname and its audience — the members of the
5
+ * domain. `plugins.domain` (state.db 0016); NULL is the primary.
6
+ */
7
+
8
+ import { withTunnelLock } from '@lamalibre/lamaste/server';
9
+ import { tunnelUserSurfaceRoutes } from '@lamalibre/lamaste';
10
+ import { httpError } from './app-error.js';
11
+ import { recordAuditBestEffort } from './audit.js';
12
+ import { pluginDomainName, resolveActiveDomain } from './domains.js';
13
+ import { findPlugin } from './plugins.js';
14
+ import { getStateDb } from './state-db.js';
15
+ import { readSites, readTunnels, withSitesLock } from './state.js';
16
+
17
+ /**
18
+ * Move plugin `name` to the active domain `domain`. Refused (409
19
+ * `surface_bound`) while a tunnel is bound as its user surface or it owns a
20
+ * web surface — both are hostnames of its present domain: unbind or delete
21
+ * them first. Under the tunnel and sites locks, which every binding and web
22
+ * surface change holds too. Then its OIDC clients are written again and the
23
+ * audiences recomputed.
24
+ *
25
+ * @param {string} name
26
+ * @param {string} domain
27
+ * @param {{ actor: string, logger: import('pino').Logger }} opts
28
+ * @returns {Promise<{ name: string, domain: string, changed: boolean }>}
29
+ */
30
+ export async function setPluginDomain(name, domain, { actor, logger }) {
31
+ const result = await withTunnelLock(() =>
32
+ withSitesLock(async () => {
33
+ const plugin = await findPlugin(name);
34
+ if (!plugin) throw httpError(`Plugin "${name}" is not installed`, 404, { code: 'not_found' });
35
+ const target = await resolveActiveDomain(domain);
36
+ const current = await pluginDomainName(plugin);
37
+ if (current === target) return { name, domain: target, changed: false };
38
+ if (plugin.route) {
39
+ const [tunnels, sites] = await Promise.all([readTunnels(), readSites()]);
40
+ const bound = tunnels.find((t) => tunnelUserSurfaceRoutes(t).includes(plugin.route));
41
+ const owned = sites.find((s) => s.owner?.plugin === plugin.route);
42
+ if (bound || owned) {
43
+ throw httpError(
44
+ `Plugin "${name}" is served on ${(bound ?? owned).fqdn ?? 'a host'} of ${current} — unbind its user surface and delete its web surface first`,
45
+ 409,
46
+ { code: 'surface_bound' },
47
+ );
48
+ }
49
+ }
50
+ const db = await getStateDb();
51
+ db.prepare('UPDATE plugins SET domain = ? WHERE name = ?').run(target, name);
52
+ await recordAuditBestEffort(
53
+ {
54
+ actor,
55
+ action: 'plugin.domain.changed',
56
+ plugin: name,
57
+ details: { from: current, to: target },
58
+ },
59
+ logger,
60
+ );
61
+ return { name, domain: target, changed: true };
62
+ }),
63
+ );
64
+ if (result.changed) {
65
+ const { syncOidcClients } = await import('./plugin-oidc.js');
66
+ try {
67
+ await syncOidcClients(logger);
68
+ } catch (err) {
69
+ logger.warn(
70
+ { plugin: name, err: err.message },
71
+ 'OIDC clients not rewritten for a new domain',
72
+ );
73
+ // The identity tick syncs them again within a minute; until then the
74
+ // old domain's members still sign in to its clients — say so.
75
+ result.warnings = [
76
+ {
77
+ step: 'oidc',
78
+ message: `The plugin's OIDC clients still admit the previous domain's members (${err.message}); the panel retries within a minute`,
79
+ },
80
+ ];
81
+ }
82
+ const { recomputeAudiences } = await import('./audience.js');
83
+ await recomputeAudiences({ logger });
84
+ }
85
+ return result;
86
+ }
@@ -938,6 +938,7 @@ async function applyUpdate(op, { plugin, staged, approvedBy, actor, logger }) {
938
938
  plugin.packageName,
939
939
  plugin.version,
940
940
  logger,
941
+ { restoring: true },
941
942
  );
942
943
  await writePluginRow(
943
944
  name,