@lamalibre/create-lamaste 4.4.1 → 4.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.
Files changed (149) hide show
  1. package/package.json +2 -2
  2. package/scripts/lamaste-priv +200 -16
  3. package/src/index.js +1 -0
  4. package/src/lib/backend-cookie.js +65 -0
  5. package/src/lib/ip-panel-vhost.js +208 -0
  6. package/src/lib/service-config.js +5 -3
  7. package/src/tasks/nginx.js +18 -135
  8. package/src/tasks/redeploy.js +39 -1
  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 +1 -1
  13. package/vendor/docs/01-concepts/authorization.md +66 -48
  14. package/vendor/docs/01-concepts/certificates.md +3 -3
  15. package/vendor/docs/01-concepts/mtls.md +2 -2
  16. package/vendor/docs/01-concepts/security-model.md +70 -33
  17. package/vendor/docs/01-concepts/tickets.md +2 -2
  18. package/vendor/docs/01-concepts/tunneling.md +4 -4
  19. package/vendor/docs/02-guides/agent-setup.md +5 -5
  20. package/vendor/docs/02-guides/certificate-management.md +2 -2
  21. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  22. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  23. package/vendor/docs/02-guides/installation.md +3 -3
  24. package/vendor/docs/02-guides/managing-users.md +113 -25
  25. package/vendor/docs/02-guides/onboarding.md +2 -2
  26. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  27. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  28. package/vendor/docs/03-architecture/installer.md +4 -4
  29. package/vendor/docs/03-architecture/management-flow.md +3 -1
  30. package/vendor/docs/03-architecture/nginx-configuration.md +165 -18
  31. package/vendor/docs/03-architecture/overview.md +13 -17
  32. package/vendor/docs/03-architecture/panel-client.md +6 -6
  33. package/vendor/docs/03-architecture/panel-server.md +3 -2
  34. package/vendor/docs/03-architecture/state-management.md +4 -8
  35. package/vendor/docs/03-architecture/system-overview.md +1 -1
  36. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +291 -22
  37. package/vendor/docs/04-api-reference/app-host.md +3 -3
  38. package/vendor/docs/04-api-reference/gatekeeper.md +60 -5
  39. package/vendor/docs/04-api-reference/lifecycle-hooks.md +14 -12
  40. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  41. package/vendor/docs/04-api-reference/registry-and-oidc.md +3 -3
  42. package/vendor/docs/04-api-reference/tunnels.md +67 -29
  43. package/vendor/docs/04-api-reference/users.md +241 -45
  44. package/vendor/docs/05-operations/backup-and-restore.md +35 -26
  45. package/vendor/docs/05-operations/monitoring.md +39 -0
  46. package/vendor/docs/05-operations/uninstalling.md +2 -1
  47. package/vendor/docs/05-operations/upgrades.md +15 -6
  48. package/vendor/docs/06-reference/config-files.md +82 -188
  49. package/vendor/docs/06-reference/glossary.md +3 -3
  50. package/vendor/docs/06-reference/installer-flags.md +13 -13
  51. package/vendor/docs/06-reference/ports-and-services.md +25 -26
  52. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  53. package/vendor/docs/gatekeeper-design.md +10 -8
  54. package/vendor/docs/identity-directory-design.md +48 -14
  55. package/vendor/docs/package.json +1 -1
  56. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  57. package/vendor/gatekeeper/dist/index.d.ts +2 -1
  58. package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
  59. package/vendor/gatekeeper/dist/index.js +2 -0
  60. package/vendor/gatekeeper/dist/index.js.map +1 -1
  61. package/vendor/gatekeeper/dist/lib/grants.d.ts +6 -0
  62. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
  63. package/vendor/gatekeeper/dist/lib/grants.js +6 -1
  64. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
  65. package/vendor/gatekeeper/dist/lib/groups.d.ts +5 -0
  66. package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -1
  67. package/vendor/gatekeeper/dist/lib/groups.js +5 -1
  68. package/vendor/gatekeeper/dist/lib/groups.js.map +1 -1
  69. package/vendor/gatekeeper/dist/lib/principals.d.ts +29 -0
  70. package/vendor/gatekeeper/dist/lib/principals.d.ts.map +1 -0
  71. package/vendor/gatekeeper/dist/lib/principals.js +67 -0
  72. package/vendor/gatekeeper/dist/lib/principals.js.map +1 -0
  73. package/vendor/gatekeeper/dist/lib/tunnels.d.ts +13 -0
  74. package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -0
  75. package/vendor/gatekeeper/dist/lib/tunnels.js +68 -0
  76. package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -0
  77. package/vendor/gatekeeper/dist/lib/types.d.ts +19 -0
  78. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  79. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  80. package/vendor/gatekeeper/dist/server/index.js +22 -48
  81. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  82. package/vendor/gatekeeper/dist/server/routes/authz.d.ts +15 -1
  83. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  84. package/vendor/gatekeeper/dist/server/routes/authz.js +250 -103
  85. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  86. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
  87. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +61 -4
  88. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
  89. package/vendor/gatekeeper/dist/server/routes/principals.d.ts +3 -0
  90. package/vendor/gatekeeper/dist/server/routes/principals.d.ts.map +1 -0
  91. package/vendor/gatekeeper/dist/server/routes/principals.js +27 -0
  92. package/vendor/gatekeeper/dist/server/routes/principals.js.map +1 -0
  93. package/vendor/gatekeeper/package.json +2 -2
  94. package/vendor/server/bin/lamaste-server.js +15 -1
  95. package/vendor/server/package.json +2 -2
  96. package/vendor/server/src/commands/plugin-identity.js +335 -0
  97. package/vendor/server/src/commands/plugins.js +99 -11
  98. package/vendor/server/src/commands/registry-credential.js +2 -1
  99. package/vendor/server/src/commands/tunnels.js +102 -2
  100. package/vendor/server/src/commands/uninstall.js +3 -1
  101. package/vendor/server/src/commands/users.js +358 -0
  102. package/vendor/server/src/plugin-approval.js +107 -5
  103. package/vendor/server/src/plugin-identity.js +382 -0
  104. package/vendor/server-ui/dist/assets/index-D-fer5ef.css +1 -0
  105. package/vendor/server-ui/dist/assets/index-DSaDPJaw.js +84 -0
  106. package/vendor/server-ui/dist/index.html +2 -2
  107. package/vendor/serverd/package.json +2 -2
  108. package/vendor/serverd/src/index.js +25 -1
  109. package/vendor/serverd/src/lib/audience.js +576 -0
  110. package/vendor/serverd/src/lib/authelia.js +78 -7
  111. package/vendor/serverd/src/lib/config.js +68 -43
  112. package/vendor/serverd/src/lib/directory.js +1113 -0
  113. package/vendor/serverd/src/lib/entitlements.js +517 -0
  114. package/vendor/serverd/src/lib/gatekeeper-client.js +215 -10
  115. package/vendor/serverd/src/lib/hook-pipelines.js +13 -0
  116. package/vendor/serverd/src/lib/identity-sync.js +905 -0
  117. package/vendor/serverd/src/lib/migrations/state/0013_identity_directory.sql +158 -0
  118. package/vendor/serverd/src/lib/migrations/state/0014_identity_ledger.sql +20 -0
  119. package/vendor/serverd/src/lib/mtls.js +6 -2
  120. package/vendor/serverd/src/lib/nginx.js +279 -48
  121. package/vendor/serverd/src/lib/plugin-hooks.js +98 -45
  122. package/vendor/serverd/src/lib/plugin-hosting.js +33 -5
  123. package/vendor/serverd/src/lib/plugin-lifecycle.js +100 -9
  124. package/vendor/serverd/src/lib/plugins.js +59 -4
  125. package/vendor/serverd/src/lib/registry-credential.js +9 -6
  126. package/vendor/serverd/src/lib/request-logging.js +19 -0
  127. package/vendor/serverd/src/lib/retained-data.js +50 -4
  128. package/vendor/serverd/src/lib/state.js +18 -0
  129. package/vendor/serverd/src/lib/tunnel-hooks.js +4 -1
  130. package/vendor/serverd/src/lib/user-access-session.js +21 -11
  131. package/vendor/serverd/src/lib/user-caller.js +67 -0
  132. package/vendor/serverd/src/lib/user-surface.js +733 -0
  133. package/vendor/serverd/src/lib/vhost-reconcile.js +136 -34
  134. package/vendor/serverd/src/middleware/user-access-session.js +9 -0
  135. package/vendor/serverd/src/routes/agent-forwarder.js +4 -2
  136. package/vendor/serverd/src/routes/invite.js +53 -12
  137. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +107 -16
  138. package/vendor/serverd/src/routes/management/identity-directory.js +79 -0
  139. package/vendor/serverd/src/routes/management/invitations.js +78 -68
  140. package/vendor/serverd/src/routes/management/plugin-identity.js +221 -0
  141. package/vendor/serverd/src/routes/management/registry-credential.js +1 -1
  142. package/vendor/serverd/src/routes/management/tunnels.js +98 -2
  143. package/vendor/serverd/src/routes/management/users.js +290 -208
  144. package/vendor/serverd/src/routes/management.js +4 -0
  145. package/vendor/serverd/src/routes/onboarding/provision.js +14 -1
  146. package/vendor/serverd/src/routes/user-access.js +17 -4
  147. package/vendor/serverd/src/routes/user-surface.js +69 -0
  148. package/vendor/server-ui/dist/assets/index-25wo_8av.css +0 -1
  149. package/vendor/server-ui/dist/assets/index-roDHz3dM.js +0 -84
@@ -1,20 +1,21 @@
1
1
  import crypto from 'node:crypto';
2
2
  import { z } from 'zod';
3
- import { readInvitations, writeInvitations } from '../../lib/state.js';
3
+ import { USERNAME_MAX_LENGTH, USERNAME_MIN_LENGTH, USERNAME_REGEX } from '@lamalibre/lamaste';
4
+ import { readInvitations, withInvitationsLock, writeInvitations } from '../../lib/state.js';
4
5
  import { readUsersRaw } from '../../lib/authelia.js';
5
6
  import { getConfig } from '../../lib/config.js';
6
7
 
7
8
  const IdParamSchema = z.object({ id: z.string().uuid() });
8
9
 
9
10
  const CreateInvitationSchema = z.object({
11
+ // Same rule as the users API (core's USERNAME_REGEX). A leading "-" would
12
+ // read as a flag to Authelia's CLI when a TOTP secret is stored for the user.
10
13
  username: z
11
14
  .string()
12
- .min(2)
13
- .max(32)
15
+ .min(USERNAME_MIN_LENGTH)
16
+ .max(USERNAME_MAX_LENGTH)
14
17
  .regex(
15
- // Same rule as the users API. A leading "-" would read as a flag to
16
- // Authelia's CLI when a TOTP secret is stored for the user.
17
- /^[a-z0-9][a-z0-9_-]*$/,
18
+ USERNAME_REGEX,
18
19
  'Username must start with a lowercase letter or digit and may contain underscores or hyphens',
19
20
  ),
20
21
  email: z.string().email(),
@@ -67,50 +68,55 @@ export default async function invitationRoutes(fastify, _opts) {
67
68
  return reply.code(409).send({ error: 'Username already exists as an Authelia user' });
68
69
  }
69
70
 
70
- // Check username doesn't already have a pending invitation
71
- let invitations;
72
- try {
73
- invitations = await readInvitations();
74
- } catch (err) {
75
- request.log.error(err, 'Failed to read invitations');
76
- return reply.code(500).send({ error: 'Failed to read invitations' });
77
- }
71
+ // The pending-invitation check and the write hold the invitations
72
+ // lock: nothing else may rewrite the file between them.
73
+ const outcome = await withInvitationsLock(async () => {
74
+ let invitations;
75
+ try {
76
+ invitations = await readInvitations();
77
+ } catch (err) {
78
+ request.log.error(err, 'Failed to read invitations');
79
+ return { status: 500, body: { error: 'Failed to read invitations' } };
80
+ }
78
81
 
79
- const now = new Date();
80
- const existingPending = invitations.find(
81
- (inv) => inv.username === body.username && !inv.used && new Date(inv.expiresAt) > now,
82
- );
83
- if (existingPending) {
84
- return reply
85
- .code(409)
86
- .send({ error: 'A pending invitation already exists for this username' });
87
- }
82
+ const now = new Date();
83
+ const existingPending = invitations.find(
84
+ (inv) => inv.username === body.username && !inv.used && new Date(inv.expiresAt) > now,
85
+ );
86
+ if (existingPending) {
87
+ return {
88
+ status: 409,
89
+ body: { error: 'A pending invitation already exists for this username' },
90
+ };
91
+ }
92
+
93
+ const expiresAt = new Date(now.getTime() + body.expiresInDays * 24 * 60 * 60 * 1000);
94
+ const invitation = {
95
+ id: crypto.randomUUID(),
96
+ token: crypto.randomBytes(32).toString('hex'),
97
+ username: body.username,
98
+ email: body.email,
99
+ groups: body.groups,
100
+ createdAt: now.toISOString(),
101
+ expiresAt: expiresAt.toISOString(),
102
+ used: false,
103
+ acceptedAt: null,
104
+ };
105
+ invitations.push(invitation);
106
+
107
+ try {
108
+ await writeInvitations(invitations);
109
+ } catch (err) {
110
+ request.log.error(err, 'Failed to write invitations');
111
+ return { status: 500, body: { error: 'Failed to save invitation' } };
112
+ }
113
+ return { status: 201, invitation };
114
+ });
115
+ if (outcome.status !== 201) return reply.code(outcome.status).send(outcome.body);
88
116
 
117
+ const { invitation } = outcome;
118
+ const { token } = invitation;
89
119
  const config = getConfig();
90
- const token = crypto.randomBytes(32).toString('hex');
91
- const expiresAt = new Date(now.getTime() + body.expiresInDays * 24 * 60 * 60 * 1000);
92
-
93
- const invitation = {
94
- id: crypto.randomUUID(),
95
- token,
96
- username: body.username,
97
- email: body.email,
98
- groups: body.groups,
99
- createdAt: now.toISOString(),
100
- expiresAt: expiresAt.toISOString(),
101
- used: false,
102
- acceptedAt: null,
103
- };
104
-
105
- invitations.push(invitation);
106
-
107
- try {
108
- await writeInvitations(invitations);
109
- } catch (err) {
110
- request.log.error(err, 'Failed to write invitations');
111
- return reply.code(500).send({ error: 'Failed to save invitation' });
112
- }
113
-
114
120
  const inviteUrl = config.domain ? `https://auth.${config.domain}/invite/${token}` : null;
115
121
 
116
122
  return reply.code(201).send({
@@ -138,27 +144,31 @@ export default async function invitationRoutes(fastify, _opts) {
138
144
  async (request, reply) => {
139
145
  const { id } = IdParamSchema.parse(request.params);
140
146
 
141
- let invitations;
142
- try {
143
- invitations = await readInvitations();
144
- } catch (err) {
145
- request.log.error(err, 'Failed to read invitations');
146
- return reply.code(500).send({ error: 'Failed to read invitations' });
147
- }
148
-
149
- const index = invitations.findIndex((inv) => inv.id === id);
150
- if (index === -1) {
151
- return reply.code(404).send({ error: 'Invitation not found' });
152
- }
153
-
154
- invitations.splice(index, 1);
155
-
156
- try {
157
- await writeInvitations(invitations);
158
- } catch (err) {
159
- request.log.error(err, 'Failed to write invitations');
160
- return reply.code(500).send({ error: 'Failed to revoke invitation' });
161
- }
147
+ const outcome = await withInvitationsLock(async () => {
148
+ let invitations;
149
+ try {
150
+ invitations = await readInvitations();
151
+ } catch (err) {
152
+ request.log.error(err, 'Failed to read invitations');
153
+ return { status: 500, body: { error: 'Failed to read invitations' } };
154
+ }
155
+
156
+ const index = invitations.findIndex((inv) => inv.id === id);
157
+ if (index === -1) {
158
+ return { status: 404, body: { error: 'Invitation not found' } };
159
+ }
160
+
161
+ invitations.splice(index, 1);
162
+
163
+ try {
164
+ await writeInvitations(invitations);
165
+ } catch (err) {
166
+ request.log.error(err, 'Failed to write invitations');
167
+ return { status: 500, body: { error: 'Failed to revoke invitation' } };
168
+ }
169
+ return { status: 200 };
170
+ });
171
+ if (outcome.status !== 200) return reply.code(outcome.status).send(outcome.body);
162
172
 
163
173
  return { ok: true };
164
174
  },
@@ -0,0 +1,221 @@
1
+ /**
2
+ * A plugin's users (slice 6): what its identity block declares, who is
3
+ * entitled to it with which roles, its audience as the agent half is told,
4
+ * and the feed that tells it. Admin only. Business logic lives in
5
+ * `lib/entitlements.js`, `lib/audience.js` and `lib/identity-sync.js`.
6
+ *
7
+ * GET /plugins/:name/identity declared block, approval, readiness,
8
+ * sync state, audience counts, pending erasures
9
+ * GET /plugins/:name/audience members and tombstones, in seq order
10
+ * GET /plugins/:name/entitlements role grants (and how many grants carry no roles)
11
+ * PUT /plugins/:name/entitlements { principalType, principalId, roles }
12
+ * DELETE /plugins/:name/entitlements?principalType=&principalId=
13
+ * POST /plugins/:name/identity/sync { reconcile? } — sync now
14
+ */
15
+
16
+ import { z } from 'zod';
17
+ import { USERNAME_MAX_LENGTH, USERNAME_MIN_LENGTH, USERNAME_REGEX } from '@lamalibre/lamaste';
18
+ import { actorFromRequest } from '../../lib/actor.js';
19
+ import { listAudience } from '../../lib/audience.js';
20
+ import { listEntitlements, removeEntitlement, setEntitlement } from '../../lib/entitlements.js';
21
+ import { identityView, syncPluginIdentity } from '../../lib/identity-sync.js';
22
+ import { hookStatus } from '../../lib/plugin-hooks.js';
23
+ import { findPlugin } from '../../lib/plugins.js';
24
+
25
+ const PluginNameParamSchema = z.object({
26
+ name: z
27
+ .string()
28
+ .min(1)
29
+ .max(100)
30
+ .regex(/^[a-z0-9-]+$/, 'Plugin name must contain only lowercase letters, numbers, and hyphens'),
31
+ });
32
+
33
+ /** A user principal is a username, a group principal a Lamaste group name (Gatekeeper's rule). */
34
+ const USER_PRINCIPAL = {
35
+ principalType: z.literal('user'),
36
+ principalId: z
37
+ .string()
38
+ .min(USERNAME_MIN_LENGTH)
39
+ .max(USERNAME_MAX_LENGTH)
40
+ .regex(USERNAME_REGEX, 'principalId is a username'),
41
+ };
42
+ const GROUP_PRINCIPAL = {
43
+ principalType: z.literal('group'),
44
+ principalId: z
45
+ .string()
46
+ .min(2)
47
+ .max(63)
48
+ .regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, 'principalId is a group name'),
49
+ };
50
+ /** Role ids are checked against the manifest by the library. */
51
+ const ROLES = z.array(z.string().min(1).max(32)).min(1).max(32);
52
+
53
+ const PrincipalSchema = z.discriminatedUnion('principalType', [
54
+ z.object(USER_PRINCIPAL).strict(),
55
+ z.object(GROUP_PRINCIPAL).strict(),
56
+ ]);
57
+
58
+ const EntitlementBodySchema = z.discriminatedUnion('principalType', [
59
+ z.object({ ...USER_PRINCIPAL, roles: ROLES }).strict(),
60
+ z.object({ ...GROUP_PRINCIPAL, roles: ROLES }).strict(),
61
+ ]);
62
+
63
+ const SyncBodySchema = z.object({ reconcile: z.boolean().optional() }).strict();
64
+
65
+ /** The refusal a library threw (its `statusCode`, message and `code`), or a 500. */
66
+ function sendFailure(request, reply, err, fallback) {
67
+ if (Number.isInteger(err.statusCode)) {
68
+ return reply.code(err.statusCode).send({
69
+ error: err.message,
70
+ ...(err.code ? { code: err.code } : {}),
71
+ ...(Array.isArray(err.unknown) ? { unknown: err.unknown } : {}),
72
+ });
73
+ }
74
+ request.log.error(err, fallback);
75
+ return reply.code(500).send({ error: fallback });
76
+ }
77
+
78
+ /** The installed plugin, answering 404 (not installed) or 400 `no_identity` (when it must declare identity). */
79
+ async function pluginFor(request, reply, { requireIdentity }) {
80
+ const { name } = PluginNameParamSchema.parse(request.params);
81
+ const plugin = await findPlugin(name);
82
+ if (!plugin) {
83
+ reply.code(404).send({ error: `Plugin "${name}" not found` });
84
+ return null;
85
+ }
86
+ if (requireIdentity && !plugin.identity) {
87
+ reply
88
+ .code(400)
89
+ .send({ error: `Plugin "${name}" declares no identity (roles)`, code: 'no_identity' });
90
+ return null;
91
+ }
92
+ return plugin;
93
+ }
94
+
95
+ export default async function pluginIdentityRoutes(fastify, _opts) {
96
+ fastify.get(
97
+ '/plugins/:name/identity',
98
+ { preHandler: fastify.requireRole(['admin']) },
99
+ async (request, reply) => {
100
+ const plugin = await pluginFor(request, reply, { requireIdentity: false });
101
+ if (!plugin) return reply;
102
+ const [view, hooks] = await Promise.all([identityView(plugin.name), hookStatus(plugin)]);
103
+ return {
104
+ identity: {
105
+ ...view,
106
+ approval: { approved: hooks.identity.approved, state: hooks.approval.state },
107
+ },
108
+ };
109
+ },
110
+ );
111
+
112
+ fastify.get(
113
+ '/plugins/:name/audience',
114
+ { preHandler: fastify.requireRole(['admin']) },
115
+ async (request, reply) => {
116
+ const plugin = await pluginFor(request, reply, { requireIdentity: true });
117
+ if (!plugin) return reply;
118
+ const rows = await listAudience(plugin.name);
119
+ return {
120
+ head: rows.reduce((head, row) => Math.max(head, row.seq), 0),
121
+ audience: rows.map((row) => ({
122
+ userId: row.userId,
123
+ seq: row.seq,
124
+ version: row.version,
125
+ updatedAt: row.updatedAt,
126
+ change: row.change,
127
+ })),
128
+ };
129
+ },
130
+ );
131
+
132
+ fastify.get(
133
+ '/plugins/:name/entitlements',
134
+ { preHandler: fastify.requireRole(['admin']) },
135
+ async (request, reply) => {
136
+ const plugin = await pluginFor(request, reply, { requireIdentity: true });
137
+ if (!plugin) return reply;
138
+ try {
139
+ return await listEntitlements(plugin);
140
+ } catch (err) {
141
+ return sendFailure(request, reply, err, 'Failed to read the entitlements');
142
+ }
143
+ },
144
+ );
145
+
146
+ fastify.put(
147
+ '/plugins/:name/entitlements',
148
+ {
149
+ preHandler: fastify.requireRole(['admin']),
150
+ config: { rateLimit: { max: 30, timeWindow: '1 minute' } },
151
+ },
152
+ async (request, reply) => {
153
+ const plugin = await pluginFor(request, reply, { requireIdentity: true });
154
+ if (!plugin) return reply;
155
+ const body = EntitlementBodySchema.parse(request.body);
156
+ try {
157
+ const result = await setEntitlement(plugin, body, {
158
+ actor: actorFromRequest(request),
159
+ logger: request.log,
160
+ });
161
+ return {
162
+ ok: true,
163
+ entitlement: result.entitlement,
164
+ changed: result.changed,
165
+ ...(result.warnings.length > 0 ? { warnings: result.warnings } : {}),
166
+ };
167
+ } catch (err) {
168
+ return sendFailure(request, reply, err, 'Failed to set the entitlement');
169
+ }
170
+ },
171
+ );
172
+
173
+ fastify.delete(
174
+ '/plugins/:name/entitlements',
175
+ {
176
+ preHandler: fastify.requireRole(['admin']),
177
+ config: { rateLimit: { max: 30, timeWindow: '1 minute' } },
178
+ },
179
+ async (request, reply) => {
180
+ const plugin = await pluginFor(request, reply, { requireIdentity: true });
181
+ if (!plugin) return reply;
182
+ const principal = PrincipalSchema.parse(request.query ?? {});
183
+ try {
184
+ const result = await removeEntitlement(plugin, principal, {
185
+ actor: actorFromRequest(request),
186
+ logger: request.log,
187
+ });
188
+ return {
189
+ ok: true,
190
+ revoked: result.revoked,
191
+ ...(result.warnings.length > 0 ? { warnings: result.warnings } : {}),
192
+ };
193
+ } catch (err) {
194
+ return sendFailure(request, reply, err, 'Failed to remove the entitlement');
195
+ }
196
+ },
197
+ );
198
+
199
+ fastify.post(
200
+ '/plugins/:name/identity/sync',
201
+ {
202
+ preHandler: fastify.requireRole(['admin']),
203
+ config: { rateLimit: { max: 10, timeWindow: '1 minute' } },
204
+ },
205
+ async (request, reply) => {
206
+ const plugin = await pluginFor(request, reply, { requireIdentity: true });
207
+ if (!plugin) return reply;
208
+ const body = SyncBodySchema.parse(request.body ?? {});
209
+ const result = await syncPluginIdentity(plugin.name, {
210
+ reconcile: body.reconcile === true,
211
+ logger: request.log,
212
+ });
213
+ const view = await identityView(plugin.name);
214
+ return {
215
+ ok: result.status === 'synced' || result.status === 'up-to-date',
216
+ result,
217
+ sync: view?.sync ?? null,
218
+ };
219
+ },
220
+ );
221
+ }
@@ -4,7 +4,7 @@
4
4
  * GET /api/registry-credential what is configured — never a token
5
5
  * PUT /api/registry-credential store a credential after verifying the
6
6
  * whole chain against the registry
7
- * (admin scope required)
7
+ * (read + admin scopes required)
8
8
  * DELETE /api/registry-credential remove it
9
9
  *
10
10
  * `auth.kind` is `token` (a pasted long-lived Feria token) or `oidc` (the
@@ -5,6 +5,15 @@
5
5
  * under the tunnel-state lock. Read-only endpoints and HTTP-specific auth
6
6
  * checks remain here; checks that depend on the tunnel's current state run
7
7
  * inside the lock through the workflows' `authorize` hook.
8
+ *
9
+ * A tunnel may serve agent-hosted plugins' user surfaces (slice 6, admin
10
+ * only; `lib/user-surface.js`):
11
+ *
12
+ * PUT /tunnels/:id/user-surface { route } — bind
13
+ * DELETE /tunnels/:id/user-surface/:route unbind
14
+ *
15
+ * Listings carry the binding (`userSurface: { routes }`) and how each bound
16
+ * route is served (`userSurfaceStatus`).
8
17
  */
9
18
  import { z } from 'zod';
10
19
  import {
@@ -30,6 +39,14 @@ import { buildChiselArgs } from '../../lib/chisel-args.js';
30
39
  import { listAgentPluginAssignments } from '../../lib/plugin-hosting.js';
31
40
  import { pendingAgentJobs } from '../../lib/agent-jobs.js';
32
41
  import { recordAgentContact } from '../../lib/agent-presence.js';
42
+ import { actorFromRequest } from '../../lib/actor.js';
43
+ import {
44
+ auditDeletedTunnelUserSurface,
45
+ bindTunnelUserSurface,
46
+ unbindTunnelUserSurface,
47
+ userSurfaceDeps,
48
+ withUserSurfaceStatus,
49
+ } from '../../lib/user-surface.js';
33
50
  import {
34
51
  tunnelNginxDeps,
35
52
  tunnelCertbotDeps,
@@ -39,6 +56,20 @@ import {
39
56
 
40
57
  const IdParamSchema = z.object({ id: z.string().uuid() });
41
58
 
59
+ /** A plugin route, as a user surface is bound for it (core `isUserSurfaceRoute` decides the rest). */
60
+ const UserSurfaceRouteSchema = z
61
+ .string()
62
+ .min(1)
63
+ .max(64)
64
+ .regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, 'route must be a plugin route');
65
+
66
+ const UserSurfaceBodySchema = z.object({ route: UserSurfaceRouteSchema }).strict();
67
+
68
+ const UserSurfaceParamsSchema = z.object({ id: z.string().uuid(), route: UserSurfaceRouteSchema });
69
+
70
+ /** Binding and unbinding rewrite a vhost and reload nginx. */
71
+ const USER_SURFACE_WRITE_RATE_LIMIT = { rateLimit: { max: 30, timeWindow: '1 minute' } };
72
+
42
73
  const TunnelPortSchema = z
43
74
  .number()
44
75
  .int('Port must be an integer')
@@ -215,11 +246,30 @@ function tunnelErrorStatus(code) {
215
246
  return 403;
216
247
  case 'NOT_FOUND':
217
248
  return 404;
249
+ case 'USER_SURFACE_CONFLICT':
250
+ return 409;
218
251
  default:
219
252
  return 500;
220
253
  }
221
254
  }
222
255
 
256
+ /** A refused or failed user-surface change: the workflow's status, or a 500. */
257
+ function sendUserSurfaceFailure(request, reply, err, fallback) {
258
+ if (err instanceof TunnelError) {
259
+ const status = tunnelErrorStatus(err.code);
260
+ return reply.code(status).send({
261
+ error: status === 500 ? fallback : err.message,
262
+ ...(status === 500 ? { details: err.message } : {}),
263
+ ...(err.code === 'USER_SURFACE_CONFLICT' ? { code: 'user_surface_conflict' } : {}),
264
+ });
265
+ }
266
+ if (Number.isInteger(err.statusCode)) {
267
+ return reply.code(err.statusCode).send({ error: err.message });
268
+ }
269
+ request.log.error(err, fallback);
270
+ return reply.code(500).send({ error: fallback, details: err.message });
271
+ }
272
+
223
273
  export default async function tunnelRoutes(fastify, _opts) {
224
274
  const nginxDeps = tunnelNginxDeps;
225
275
  const certbotDeps = tunnelCertbotDeps;
@@ -348,7 +398,9 @@ export default async function tunnelRoutes(fastify, _opts) {
348
398
  });
349
399
 
350
400
  const total = tunnels.length;
351
- const windowed = tunnels.slice(offset, offset + limit);
401
+ const windowed = await Promise.all(
402
+ tunnels.slice(offset, offset + limit).map((t) => withUserSurfaceStatus(t)),
403
+ );
352
404
 
353
405
  return { tunnels: windowed, total, limit, offset };
354
406
  },
@@ -547,6 +599,7 @@ export default async function tunnelRoutes(fastify, _opts) {
547
599
  chisel: chiselDeps,
548
600
  state: stateDeps,
549
601
  agents: agentDeps,
602
+ userSurface: userSurfaceDeps,
550
603
  logger: request.log,
551
604
  authorize: authorizeFor(request, 'change'),
552
605
  });
@@ -577,7 +630,7 @@ export default async function tunnelRoutes(fastify, _opts) {
577
630
  const { id } = IdParamSchema.parse(request.params);
578
631
 
579
632
  try {
580
- await deleteTunnel({
633
+ const { tunnel } = await deleteTunnel({
581
634
  id,
582
635
  nginx: nginxDeps,
583
636
  chisel: chiselDeps,
@@ -585,6 +638,11 @@ export default async function tunnelRoutes(fastify, _opts) {
585
638
  logger: request.log,
586
639
  authorize: authorizeFor(request, 'delete'),
587
640
  });
641
+ // Its user-surface bindings went with it.
642
+ await auditDeletedTunnelUserSurface(tunnel, {
643
+ actor: actorFromRequest(request),
644
+ logger: request.log,
645
+ });
588
646
  return { ok: true };
589
647
  } catch (err) {
590
648
  if (err instanceof TunnelError) {
@@ -603,6 +661,44 @@ export default async function tunnelRoutes(fastify, _opts) {
603
661
  },
604
662
  );
605
663
 
664
+ // PUT /api/tunnels/:id/user-surface — serve a plugin's user surface on this
665
+ // tunnel's host (from when its installed, approved version declares identity)
666
+ fastify.put(
667
+ '/tunnels/:id/user-surface',
668
+ { preHandler: fastify.requireRole(['admin']), config: USER_SURFACE_WRITE_RATE_LIMIT },
669
+ async (request, reply) => {
670
+ const { id } = IdParamSchema.parse(request.params);
671
+ const { route } = UserSurfaceBodySchema.parse(request.body);
672
+ try {
673
+ const { changed, tunnel } = await bindTunnelUserSurface(id, route, {
674
+ actor: actorFromRequest(request),
675
+ logger: request.log,
676
+ });
677
+ return { ok: true, changed, tunnel };
678
+ } catch (err) {
679
+ return sendUserSurfaceFailure(request, reply, err, 'Failed to bind the user surface');
680
+ }
681
+ },
682
+ );
683
+
684
+ // DELETE /api/tunnels/:id/user-surface/:route — stop serving it there
685
+ fastify.delete(
686
+ '/tunnels/:id/user-surface/:route',
687
+ { preHandler: fastify.requireRole(['admin']), config: USER_SURFACE_WRITE_RATE_LIMIT },
688
+ async (request, reply) => {
689
+ const { id, route } = UserSurfaceParamsSchema.parse(request.params);
690
+ try {
691
+ const { tunnel } = await unbindTunnelUserSurface(id, route, {
692
+ actor: actorFromRequest(request),
693
+ logger: request.log,
694
+ });
695
+ return { ok: true, tunnel };
696
+ } catch (err) {
697
+ return sendUserSurfaceFailure(request, reply, err, 'Failed to unbind the user surface');
698
+ }
699
+ },
700
+ );
701
+
606
702
  // GET /api/tunnels/agent-panel-status — check if agent has a panel tunnel
607
703
  fastify.get(
608
704
  '/tunnels/agent-panel-status',