@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,154 @@
1
+ /**
2
+ * The relay's base domains and their members (the multi-domain relay,
3
+ * lib/domains.js). Admin only (mTLS).
4
+ *
5
+ * GET /api/domains domains, member counts (members, external), what uses each
6
+ * POST /api/domains add one (`pending`)
7
+ * POST /api/domains/:name/verify-dns does auth.<domain> resolve here?
8
+ * POST /api/domains/:name/activate issue auth.<domain>, serve its sign-in
9
+ * DELETE /api/domains/:name remove an unused, non-primary domain
10
+ * GET /api/domains/:name/members its members, each with the membership's kind
11
+ * PUT /api/domains/:name/members/:user make a user (id or username) a member —
12
+ * `{ kind: 'member' | 'external' }`, member
13
+ * unless named; another kind changes it
14
+ * DELETE /api/domains/:name/members/:user end a membership
15
+ * PUT /api/plugins/:name/domain move a plugin to another domain
16
+ *
17
+ * Routes validate and answer; the work is in lib/domains.js and
18
+ * lib/plugin-domain.js. Refusals answer `{ error, code }`.
19
+ */
20
+
21
+ import { z } from 'zod';
22
+ import { DomainNameSchema, MEMBERSHIP_KINDS } from '@lamalibre/lamaste';
23
+ import { actorFromRequest } from '../../lib/actor.js';
24
+ import { sendFailure } from '../../lib/app-error.js';
25
+ import {
26
+ activateDomain,
27
+ addDomain,
28
+ addDomainMember,
29
+ domainsOverview,
30
+ getDomain,
31
+ listDomainMembers,
32
+ newUsersNeedDomains,
33
+ removeDomain,
34
+ removeDomainMember,
35
+ verifyDomainDns,
36
+ } from '../../lib/domains.js';
37
+ import { setPluginDomain } from '../../lib/plugin-domain.js';
38
+
39
+ const NameParamSchema = z.object({ name: DomainNameSchema });
40
+ // `user`: a directory id or a username (lib/domains.js decides which).
41
+ const MemberParamsSchema = z.object({ name: DomainNameSchema, user: z.string().min(1).max(64) });
42
+ const AddDomainSchema = z.object({ name: DomainNameSchema }).strict();
43
+ const MemberBodySchema = z.object({ kind: z.enum(MEMBERSHIP_KINDS).optional() }).strict();
44
+ const PluginParamSchema = z.object({ name: z.string().min(1).max(200) });
45
+ const PluginDomainSchema = z.object({ domain: DomainNameSchema }).strict();
46
+
47
+ export default async function domainRoutes(fastify) {
48
+ const admin = { preHandler: fastify.requireRole(['admin']) };
49
+
50
+ fastify.get('/domains', admin, async () => {
51
+ const domains = await domainsOverview();
52
+ // Whether users and invitations must name their domains (`domains_required`).
53
+ return { domains, newUsersNeedDomains: newUsersNeedDomains(domains) };
54
+ });
55
+
56
+ fastify.post('/domains', admin, async (request, reply) => {
57
+ const { name } = AddDomainSchema.parse(request.body);
58
+ try {
59
+ const domain = await addDomain(name, {
60
+ actor: actorFromRequest(request),
61
+ logger: request.log,
62
+ });
63
+ return reply.code(201).send({ domain });
64
+ } catch (err) {
65
+ return sendFailure(request, reply, err, 'The domain change failed');
66
+ }
67
+ });
68
+
69
+ fastify.post(
70
+ '/domains/:name/verify-dns',
71
+ { ...admin, config: { rateLimit: { max: 30, timeWindow: '1 minute' } } },
72
+ async (request, reply) => {
73
+ const { name } = NameParamSchema.parse(request.params);
74
+ try {
75
+ return await verifyDomainDns(name);
76
+ } catch (err) {
77
+ return sendFailure(request, reply, err, 'The domain change failed');
78
+ }
79
+ },
80
+ );
81
+
82
+ fastify.post(
83
+ '/domains/:name/activate',
84
+ { ...admin, config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
85
+ async (request, reply) => {
86
+ const { name } = NameParamSchema.parse(request.params);
87
+ try {
88
+ const domain = await activateDomain(name, {
89
+ actor: actorFromRequest(request),
90
+ logger: request.log,
91
+ });
92
+ return { domain };
93
+ } catch (err) {
94
+ return sendFailure(request, reply, err, 'The domain change failed');
95
+ }
96
+ },
97
+ );
98
+
99
+ fastify.delete('/domains/:name', admin, async (request, reply) => {
100
+ const { name } = NameParamSchema.parse(request.params);
101
+ try {
102
+ return await removeDomain(name, { actor: actorFromRequest(request), logger: request.log });
103
+ } catch (err) {
104
+ return sendFailure(request, reply, err, 'The domain change failed');
105
+ }
106
+ });
107
+
108
+ fastify.get('/domains/:name/members', admin, async (request, reply) => {
109
+ const { name } = NameParamSchema.parse(request.params);
110
+ if (!(await getDomain(name))) {
111
+ return reply.code(404).send({ error: 'Domain not found', code: 'domain_not_found' });
112
+ }
113
+ return { members: await listDomainMembers(name) };
114
+ });
115
+
116
+ fastify.put('/domains/:name/members/:user', admin, async (request, reply) => {
117
+ const { name, user } = MemberParamsSchema.parse(request.params);
118
+ const { kind } = MemberBodySchema.parse(request.body ?? {});
119
+ try {
120
+ return await addDomainMember(name, user, {
121
+ ...(kind ? { kind } : {}),
122
+ actor: actorFromRequest(request),
123
+ logger: request.log,
124
+ });
125
+ } catch (err) {
126
+ return sendFailure(request, reply, err, 'The domain change failed');
127
+ }
128
+ });
129
+
130
+ fastify.delete('/domains/:name/members/:user', admin, async (request, reply) => {
131
+ const { name, user } = MemberParamsSchema.parse(request.params);
132
+ try {
133
+ return await removeDomainMember(name, user, {
134
+ actor: actorFromRequest(request),
135
+ logger: request.log,
136
+ });
137
+ } catch (err) {
138
+ return sendFailure(request, reply, err, 'The domain change failed');
139
+ }
140
+ });
141
+
142
+ fastify.put('/plugins/:name/domain', admin, async (request, reply) => {
143
+ const { name } = PluginParamSchema.parse(request.params);
144
+ const { domain } = PluginDomainSchema.parse(request.body);
145
+ try {
146
+ return await setPluginDomain(name, domain, {
147
+ actor: actorFromRequest(request),
148
+ logger: request.log,
149
+ });
150
+ } catch (err) {
151
+ return sendFailure(request, reply, err, 'The domain change failed');
152
+ }
153
+ });
154
+ }
@@ -21,13 +21,24 @@
21
21
  * cannot be) — a reused username inherits nothing. The access log is purged
22
22
  * for one username by POST, its name in the body (never in a URL, which
23
23
  * logs keep).
24
+ *
25
+ * Grants and groups also decide what external members of a domain reach
26
+ * (packages/core/docs/external-members-design.md): after every change that
27
+ * went through, the domains are rendered (users.yml's owned groups — the
28
+ * restricted hosts and OIDC clients that name each external member), which
29
+ * follows whoever it narrowed (registry tokens, a device-code session). A
30
+ * rendering that fails is answered with `warnings` and tried again within
31
+ * seconds. A grant on an OIDC client (`resourceType: 'oidc-client'`) must
32
+ * name a user-facing client an installed plugin declares.
24
33
  */
25
34
 
26
35
  import { recomputeAudiences } from '../../lib/audience.js';
27
36
  import { withUsernamesForGrants } from '../../lib/directory.js';
37
+ import { scheduleRenderRetry, syncDomainAccess } from '../../lib/domains.js';
28
38
  import { checkProxiedGrant } from '../../lib/entitlements.js';
29
39
  import { actorFromRequest } from '../../lib/actor.js';
30
40
  import { gatekeeperRequest } from '../../lib/gatekeeper-client.js';
41
+ import { checkOidcClientGrant } from '../../lib/owned-groups.js';
31
42
 
32
43
  /** Gatekeeper's `path`, with the request's query string when it has one. */
33
44
  function withQuery(path, request) {
@@ -55,18 +66,43 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
55
66
  };
56
67
  };
57
68
 
58
- /** When Gatekeeper took a grant or group change, the plugin audiences follow it. */
59
- const followAudiences = async (request, answer) => {
60
- if (answer.statusCode < 400) await recomputeAudiences({ logger: request.log });
61
- return answer;
69
+ /**
70
+ * When Gatekeeper took a grant or group change: the plugin audiences and
71
+ * the domains' rendering follow it — which follows whoever it narrowed —
72
+ * all before the answer goes back. When the rendering fails, the answer
73
+ * carries `warnings` (Gatekeeper took the change, and enforces it on its
74
+ * own hosts; an OIDC client it no longer grants waits for a rendering)
75
+ * and one is tried again within seconds.
76
+ */
77
+ const followChange = async (request, answer) => {
78
+ if (answer.statusCode >= 400) return answer;
79
+ await recomputeAudiences({ logger: request.log });
80
+ let warnings;
81
+ try {
82
+ warnings = (await syncDomainAccess({ logger: request.log })).warnings ?? [];
83
+ } catch (err) {
84
+ request.log.warn({ err: err.message }, 'Rendering domain access after a grant change failed');
85
+ scheduleRenderRetry(request.log);
86
+ warnings = [
87
+ ...(err.warnings ?? []),
88
+ {
89
+ step: 'render',
90
+ message: `Gatekeeper took the change, but who may reach what was not fully rendered (${err.message}); the panel tries again within seconds`,
91
+ },
92
+ ];
93
+ }
94
+ if (warnings.length === 0 || typeof answer.data !== 'object' || answer.data === null) {
95
+ return answer;
96
+ }
97
+ return { ...answer, data: { ...answer.data, warnings } };
62
98
  };
63
99
 
64
100
  /**
65
101
  * A grant or group change: forwarded, and when Gatekeeper took it, the
66
- * plugin audiences follow it before the answer goes back.
102
+ * plugin audiences and the domains follow it before the answer goes back.
67
103
  */
68
104
  const proxyChange = async (request, method, gatekeeperPath, body) =>
69
- followAudiences(request, await proxy(method, gatekeeperPath, body));
105
+ followChange(request, await proxy(method, gatekeeperPath, body));
70
106
 
71
107
  /**
72
108
  * Give something to `usernames` in Gatekeeper: forwarded holding the
@@ -85,7 +121,7 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
85
121
  if (!Number.isInteger(err.statusCode)) throw err;
86
122
  return { statusCode: err.statusCode, data: { error: err.message, code: err.code } };
87
123
  }
88
- return followAudiences(request, answer);
124
+ return followChange(request, answer);
89
125
  };
90
126
 
91
127
  // --- Groups ---
@@ -164,7 +200,7 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
164
200
  fastify.post('/gatekeeper/grants', async (request, reply) => {
165
201
  let body;
166
202
  try {
167
- body = await checkProxiedGrant(request.body);
203
+ body = await checkOidcClientGrant(await checkProxiedGrant(request.body));
168
204
  } catch (err) {
169
205
  if (!Number.isInteger(err.statusCode)) throw err;
170
206
  return reply.code(err.statusCode).send({
@@ -1,5 +1,16 @@
1
1
  import { z } from 'zod';
2
2
  import { readUsers } from '../../lib/authelia.js';
3
+ import { adminGroups, domainNames } from '../../lib/domains.js';
4
+
5
+ /**
6
+ * Authelia's users with their groups as anything outside Authelia sees
7
+ * them: the groups Lamaste owns (domain memberships, host groups, OIDC
8
+ * client grants) left out.
9
+ */
10
+ async function shownUsers() {
11
+ const [users, domains] = await Promise.all([readUsers(), domainNames()]);
12
+ return users.map((u) => ({ ...u, groups: adminGroups(u.groups, domains) }));
13
+ }
3
14
 
4
15
  const UsernameParamSchema = z.object({
5
16
  username: z
@@ -37,10 +48,13 @@ export default async function identityRoutes(fastify, _opts) {
37
48
  }
38
49
 
39
50
  const groupsRaw = headerValue(request.headers['remote-groups']) || '';
40
- const groups = groupsRaw
41
- .split(',')
42
- .map((g) => g.trim())
43
- .filter(Boolean);
51
+ const groups = adminGroups(
52
+ groupsRaw
53
+ .split(',')
54
+ .map((g) => g.trim())
55
+ .filter(Boolean),
56
+ await domainNames(),
57
+ );
44
58
  const name = headerValue(request.headers['remote-name']) || '';
45
59
  const email = headerValue(request.headers['remote-email']) || '';
46
60
 
@@ -54,7 +68,7 @@ export default async function identityRoutes(fastify, _opts) {
54
68
  { preHandler: fastify.requireRole(['admin', 'agent'], { capability: 'identity:query' }) },
55
69
  async (request, reply) => {
56
70
  try {
57
- const users = await readUsers();
71
+ const users = await shownUsers();
58
72
  return { users };
59
73
  } catch (err) {
60
74
  request.log.error({ err }, 'Failed to read Authelia users');
@@ -70,7 +84,7 @@ export default async function identityRoutes(fastify, _opts) {
70
84
  async (request, reply) => {
71
85
  const { username } = UsernameParamSchema.parse(request.params);
72
86
  try {
73
- const users = await readUsers();
87
+ const users = await shownUsers();
74
88
  const user = users.find((u) => u.username === username);
75
89
  if (!user) {
76
90
  return reply.code(404).send({ error: 'User not found' });
@@ -89,7 +103,7 @@ export default async function identityRoutes(fastify, _opts) {
89
103
  { preHandler: fastify.requireRole(['admin', 'agent'], { capability: 'identity:query' }) },
90
104
  async (request, reply) => {
91
105
  try {
92
- const users = await readUsers();
106
+ const users = await shownUsers();
93
107
  /** @type {Set<string>} */
94
108
  const groupSet = new Set();
95
109
  for (const u of users) {
@@ -3,7 +3,15 @@ import { z } from 'zod';
3
3
  import { USERNAME_MAX_LENGTH, USERNAME_MIN_LENGTH, USERNAME_REGEX } from '@lamalibre/lamaste';
4
4
  import { readInvitations, withInvitationsLock, writeInvitations } from '../../lib/state.js';
5
5
  import { readUsersRaw } from '../../lib/authelia.js';
6
- import { getConfig } from '../../lib/config.js';
6
+ import {
7
+ MembershipInputSchema,
8
+ assertAssignableGroups,
9
+ domainsForNewUser,
10
+ externalMemberGroupsError,
11
+ invitationMemberships,
12
+ invitationSignInDomain,
13
+ } from '../../lib/domains.js';
14
+ import { sendFailure } from '../../lib/app-error.js';
7
15
 
8
16
  const IdParamSchema = z.object({ id: z.string().uuid() });
9
17
 
@@ -19,8 +27,15 @@ const CreateInvitationSchema = z.object({
19
27
  'Username must start with a lowercase letter or digit and may contain underscores or hyphens',
20
28
  ),
21
29
  email: z.string().email(),
22
- groups: z.array(z.string()).optional().default([]),
30
+ // Authelia groups (`admins`, `feria-*`): letters, digits, "-" and "_" —
31
+ // never for an external member.
32
+ groups: z.array(z.string()).max(100).optional().default([]),
23
33
  expiresInDays: z.number().int().min(1).max(30).optional().default(7),
34
+ // The domains the user becomes a member of on accepting — a name (a
35
+ // member) or `{ name, kind }` (an external member reaches only what names
36
+ // them). Required while the relay has two or more; with one, the user
37
+ // becomes its member.
38
+ domains: z.array(MembershipInputSchema).max(50).optional(),
24
39
  });
25
40
 
26
41
  export default async function invitationRoutes(fastify, _opts) {
@@ -37,6 +52,7 @@ export default async function invitationRoutes(fastify, _opts) {
37
52
  const enriched = invitations.map((inv) => ({
38
53
  ...inv,
39
54
  token: undefined,
55
+ domains: invitationMemberships(inv),
40
56
  status: inv.used ? 'accepted' : new Date(inv.expiresAt) < now ? 'expired' : 'pending',
41
57
  }));
42
58
  return { invitations: enriched };
@@ -55,6 +71,16 @@ export default async function invitationRoutes(fastify, _opts) {
55
71
  },
56
72
  async (request, reply) => {
57
73
  const body = CreateInvitationSchema.parse(request.body);
74
+ let domains;
75
+ try {
76
+ assertAssignableGroups(body.groups);
77
+ domains = await domainsForNewUser(body.domains, { required: true });
78
+ if (body.groups.length > 0 && domains.some((m) => m.kind === 'external')) {
79
+ throw externalMemberGroupsError(400);
80
+ }
81
+ } catch (err) {
82
+ return sendFailure(request, reply, err, 'Failed to read the domains');
83
+ }
58
84
 
59
85
  // Check username doesn't already exist in Authelia
60
86
  let usersData;
@@ -97,6 +123,7 @@ export default async function invitationRoutes(fastify, _opts) {
97
123
  username: body.username,
98
124
  email: body.email,
99
125
  groups: body.groups,
126
+ domains,
100
127
  createdAt: now.toISOString(),
101
128
  expiresAt: expiresAt.toISOString(),
102
129
  used: false,
@@ -116,8 +143,10 @@ export default async function invitationRoutes(fastify, _opts) {
116
143
 
117
144
  const { invitation } = outcome;
118
145
  const { token } = invitation;
119
- const config = getConfig();
120
- const inviteUrl = config.domain ? `https://auth.${config.domain}/invite/${token}` : null;
146
+ // On the sign-in page of the first domain it names (the invitation
147
+ // page is served on every domain's auth portal).
148
+ const inviteDomain = await invitationSignInDomain(invitation);
149
+ const inviteUrl = inviteDomain ? `https://auth.${inviteDomain}/invite/${token}` : null;
121
150
 
122
151
  return reply.code(201).send({
123
152
  ok: true,
@@ -42,6 +42,7 @@ import {
42
42
  } from '../../lib/plugin-settings.js';
43
43
  import { actorFromRequest, certSerialFromRequest } from '../../lib/actor.js';
44
44
  import { recordAudit } from '../../lib/audit.js';
45
+ import { withPluginDomains } from '../../lib/domains.js';
45
46
  import {
46
47
  readPushInstallConfig,
47
48
  readPushInstallSessions,
@@ -337,7 +338,7 @@ export default async function pluginRoutes(fastify, _opts) {
337
338
  // (see GET /plugins/:name/settings).
338
339
  const plugins = await attachSettingsSummaries(await attachHostings(registry.plugins));
339
340
  return {
340
- plugins: await Promise.all(plugins.map(withHooks)),
341
+ plugins: await withPluginDomains(await Promise.all(plugins.map(withHooks))),
341
342
  // Every agent's retained data — of uninstalled plugins too, which
342
343
  // no entry of `plugins` lists any more.
343
344
  retainedData: await listRetainedData(),
@@ -363,7 +364,8 @@ export default async function pluginRoutes(fastify, _opts) {
363
364
  }
364
365
 
365
366
  const [withHosting] = await attachSettingsSummaries(await attachHostings([plugin]));
366
- return { plugin: await withHooks(withHosting) };
367
+ const [shown] = await withPluginDomains([await withHooks(withHosting)]);
368
+ return { plugin: shown };
367
369
  },
368
370
  );
369
371
 
@@ -9,7 +9,8 @@
9
9
  *
10
10
  * `auth.kind` is `token` (a pasted long-lived Feria token) or `oidc` (the
11
11
  * panel's own `lamaste-server` client on Authelia: client-credentials grant,
12
- * Feria login). For `oidc` the client is written to Authelia before the
12
+ * Feria login; `issuerDomain` names the active domain whose issuer,
13
+ * `https://auth.<domain>`, the registry trusts — the primary when absent). For `oidc` the client is written to Authelia before the
13
14
  * chain is verified; if verification fails it is removed again, and the
14
15
  * error says what Feria's `auth.json` needs.
15
16
  *
@@ -28,9 +29,12 @@ import {
28
29
  clearRegistryCredentialConfig,
29
30
  readRegistryCredentialConfig,
30
31
  verifyRegistryCredential,
31
- writeRegistryCredentialConfig,
32
+ setRegistryCredential,
32
33
  } from '../../lib/registry-credential.js';
33
- import { syncOidcClients } from '../../lib/plugin-oidc.js';
34
+ import { DomainNameSchema } from '@lamalibre/lamaste';
35
+ import { sendFailure } from '../../lib/app-error.js';
36
+ import { resolveActiveDomain } from '../../lib/domains.js';
37
+ import { syncOidcClients, withRegistryCandidate } from '../../lib/plugin-oidc.js';
34
38
 
35
39
  const FeriaTokenSchema = z
36
40
  .string()
@@ -40,7 +44,7 @@ const FeriaTokenSchema = z
40
44
 
41
45
  const AuthSchema = z.discriminatedUnion('kind', [
42
46
  z.object({ kind: z.literal('token'), token: FeriaTokenSchema }),
43
- z.object({ kind: z.literal('oidc') }),
47
+ z.object({ kind: z.literal('oidc'), issuerDomain: DomainNameSchema.optional() }),
44
48
  ]);
45
49
 
46
50
  const PutSchema = z
@@ -64,9 +68,13 @@ const PutSchema = z
64
68
  }
65
69
  });
66
70
 
67
- /** The stored `auth` for a request body. */
68
- function authOf(body) {
69
- if (body.auth?.kind === 'oidc') return { kind: 'oidc', clientId: LAMASTE_SERVER_OIDC_CLIENT_ID };
71
+ /** The stored `auth` for a request body (`issuerDomain` resolved against the active domains). */
72
+ async function authOf(body) {
73
+ if (body.auth?.kind === 'oidc') {
74
+ const auth = { kind: 'oidc', clientId: LAMASTE_SERVER_OIDC_CLIENT_ID };
75
+ if (body.auth.issuerDomain === undefined) return auth;
76
+ return { ...auth, issuerDomain: await resolveActiveDomain(body.auth.issuerDomain) };
77
+ }
70
78
  if (body.auth?.kind === 'token') return { kind: 'token', token: body.auth.token };
71
79
  return { kind: 'token', token: body.token };
72
80
  }
@@ -90,62 +98,83 @@ export default async function registryCredentialRoutes(fastify) {
90
98
  },
91
99
  async (request, reply) => {
92
100
  const body = PutSchema.parse(request.body);
93
- const auth = authOf(body);
101
+ let auth;
102
+ try {
103
+ auth = await authOf(body);
104
+ } catch (err) {
105
+ return sendFailure(request, reply, err, 'Failed to read the domains');
106
+ }
94
107
  const candidate = { registryUrl: body.registryUrl, auth };
95
108
 
96
- // The panel's own client must exist in Authelia (with a secret in the
97
- // store) before the chain can be exercised.
98
- if (auth.kind === 'oidc') {
99
- try {
100
- const synced = await syncOidcClients(request.log, {
101
- registryAuth: { registryUrl: body.registryUrl },
102
- });
103
- if (!synced) {
104
- return reply.code(503).send({ error: 'Complete onboarding (set a domain) first' });
109
+ // An oidc candidate is what every sync serves until it is stored or
110
+ // dropped — the identity tick's too, which would otherwise remove the
111
+ // panel's client from Authelia under the verification.
112
+ const registryAuth =
113
+ auth.kind === 'oidc'
114
+ ? {
115
+ registryUrl: body.registryUrl,
116
+ ...(auth.issuerDomain ? { issuerDomain: auth.issuerDomain } : {}),
117
+ }
118
+ : undefined;
119
+ try {
120
+ return await withRegistryCandidate(registryAuth, async () => {
121
+ // The panel's own client must exist in Authelia (with a secret in
122
+ // the store) before the chain can be exercised.
123
+ if (registryAuth) {
124
+ try {
125
+ if (!(await syncOidcClients(request.log))) {
126
+ return reply.code(503).send({ error: 'Complete onboarding (set a domain) first' });
127
+ }
128
+ } catch (err) {
129
+ return reply.code(500).send({
130
+ error: `Could not register the ${LAMASTE_SERVER_OIDC_CLIENT_ID} client in Authelia: ${err.message}`,
131
+ });
132
+ }
105
133
  }
106
- } catch (err) {
107
- return reply.code(500).send({
108
- error: `Could not register the ${LAMASTE_SERVER_OIDC_CLIENT_ID} client in Authelia: ${err.message}`,
109
- });
110
- }
111
- }
112
134
 
113
- let verified;
114
- try {
115
- verified = await verifyRegistryCredential(candidate);
116
- } catch (err) {
117
- if (auth.kind === 'oidc') {
118
- // Leave Authelia as the stored credential says — the candidate is
119
- // not kept.
120
- await syncOidcClients(request.log).catch((syncErr) =>
121
- request.log.warn({ err: syncErr.message }, 'Failed to roll back the OIDC client'),
135
+ let verified;
136
+ try {
137
+ verified = await verifyRegistryCredential(candidate);
138
+ } catch (err) {
139
+ const status = err instanceof RegistryCredentialError ? err.statusCode : 502;
140
+ return reply.code(status).send({ error: err.message });
141
+ }
142
+
143
+ const config = {
144
+ provider: 'feria',
145
+ registryUrl: body.registryUrl,
146
+ scope: body.scope ?? '@lamalibre',
147
+ auth,
148
+ owner: verified.owner,
149
+ configuredAt: new Date().toISOString(),
150
+ };
151
+ let warnings;
152
+ try {
153
+ warnings = await setRegistryCredential(config, { logger: request.log });
154
+ } catch (err) {
155
+ return sendFailure(request, reply, err, 'Failed to store the registry credential');
156
+ }
157
+ request.log.info(
158
+ { registryUrl: body.registryUrl, owner: verified.owner, auth: auth.kind },
159
+ 'Registry credential configured',
122
160
  );
123
- }
124
- const status = err instanceof RegistryCredentialError ? err.statusCode : 502;
125
- return reply.code(status).send({ error: err.message });
161
+ return {
162
+ configured: true,
163
+ ...summariseRegistryCredential(config),
164
+ ...(warnings.length > 0 ? { warnings } : {}),
165
+ };
166
+ });
167
+ } finally {
168
+ // With the candidate dropped, the stored credential decides whether
169
+ // Authelia carries the panel's client: the new one once stored, the
170
+ // previous one after a refusal (the candidate is not kept).
171
+ await syncOidcClients(request.log).catch((err) =>
172
+ request.log.warn(
173
+ { err: err.message },
174
+ 'Failed to sync OIDC clients after the registry credential request',
175
+ ),
176
+ );
126
177
  }
127
-
128
- const config = {
129
- provider: 'feria',
130
- registryUrl: body.registryUrl,
131
- scope: body.scope ?? '@lamalibre',
132
- auth,
133
- owner: verified.owner,
134
- configuredAt: new Date().toISOString(),
135
- };
136
- await writeRegistryCredentialConfig(config);
137
- // The stored credential now decides whether Authelia carries the client.
138
- await syncOidcClients(request.log).catch((err) =>
139
- request.log.warn(
140
- { err: err.message },
141
- 'Failed to sync OIDC clients after credential change',
142
- ),
143
- );
144
- request.log.info(
145
- { registryUrl: body.registryUrl, owner: verified.owner, auth: auth.kind },
146
- 'Registry credential configured',
147
- );
148
- return { configured: true, ...summariseRegistryCredential(config) };
149
178
  },
150
179
  );
151
180