@lamalibre/create-lamaste 4.0.0 → 4.1.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 (61) hide show
  1. package/package.json +2 -2
  2. package/scripts/lamaste-priv +21 -2
  3. package/src/lib/oidc-issuer-key.js +124 -0
  4. package/src/lib/relay-services.js +4 -0
  5. package/src/lib/service-config.js +16 -0
  6. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  7. package/vendor/docs/00-introduction/quickstart.md +5 -5
  8. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  9. package/vendor/docs/01-concepts/authentication.md +45 -1
  10. package/vendor/docs/01-concepts/certificates.md +3 -3
  11. package/vendor/docs/01-concepts/mtls.md +2 -2
  12. package/vendor/docs/01-concepts/tickets.md +2 -2
  13. package/vendor/docs/01-concepts/tunneling.md +4 -4
  14. package/vendor/docs/02-guides/agent-setup.md +3 -3
  15. package/vendor/docs/02-guides/certificate-management.md +2 -2
  16. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  17. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  18. package/vendor/docs/02-guides/installation.md +3 -3
  19. package/vendor/docs/02-guides/onboarding.md +2 -2
  20. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  21. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  22. package/vendor/docs/03-architecture/installer.md +6 -5
  23. package/vendor/docs/03-architecture/management-flow.md +1 -1
  24. package/vendor/docs/03-architecture/overview.md +5 -5
  25. package/vendor/docs/03-architecture/panel-client.md +6 -6
  26. package/vendor/docs/03-architecture/panel-server.md +2 -2
  27. package/vendor/docs/03-architecture/state-management.md +2 -2
  28. package/vendor/docs/03-architecture/system-overview.md +1 -1
  29. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  30. package/vendor/docs/04-api-reference/overview.md +111 -105
  31. package/vendor/docs/04-api-reference/registry-and-oidc.md +182 -0
  32. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  33. package/vendor/docs/05-operations/uninstalling.md +1 -1
  34. package/vendor/docs/05-operations/upgrades.md +6 -5
  35. package/vendor/docs/06-reference/config-files.md +103 -45
  36. package/vendor/docs/06-reference/glossary.md +3 -3
  37. package/vendor/docs/06-reference/installer-flags.md +13 -13
  38. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  39. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  40. package/vendor/docs/_index.json +5 -0
  41. package/vendor/docs/gatekeeper-design.md +8 -8
  42. package/vendor/docs/identity-directory-design.md +443 -0
  43. package/vendor/docs/package.json +1 -1
  44. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  45. package/vendor/gatekeeper/package.json +1 -1
  46. package/vendor/server/bin/lamaste-server.js +10 -1
  47. package/vendor/server/package.json +4 -3
  48. package/vendor/server/src/commands/plugins.js +87 -0
  49. package/vendor/server/src/commands/registry-credential.js +142 -0
  50. package/vendor/server/src/panel-api.js +83 -38
  51. package/vendor/serverd/package.json +5 -2
  52. package/vendor/serverd/src/index.js +11 -0
  53. package/vendor/serverd/src/lib/authelia.js +11 -0
  54. package/vendor/serverd/src/lib/plugin-oidc.js +216 -0
  55. package/vendor/serverd/src/lib/plugins.js +26 -2
  56. package/vendor/serverd/src/lib/registry-credential.js +145 -0
  57. package/vendor/serverd/src/routes/management/agents.js +81 -0
  58. package/vendor/serverd/src/routes/management/plugins.js +65 -2
  59. package/vendor/serverd/src/routes/management/registry-credential.js +170 -0
  60. package/vendor/serverd/src/routes/management/users.js +31 -0
  61. package/vendor/serverd/src/routes/management.js +2 -0
@@ -0,0 +1,443 @@
1
+ # Lamaste Identity Directory — Design Document
2
+
3
+ > **Status:** design, 2026-10-13. Nothing here is implemented yet.
4
+ > **Package:** `@lamalibre/lamaste-identity@4.1.0` (completes the existing SDK).
5
+ > **Companions:** `gatekeeper-design.md` (authorization),
6
+ > `plugin-development/design/agent-hosted-app-plugins.md` (app plugins).
7
+
8
+ ## Goal
9
+
10
+ Lamaste becomes the **directory** for every application in the ecosystem, the
11
+ way Active Directory is for a Windows estate:
12
+
13
+ - People exist **once**, in Lamaste. Applications do not create their own
14
+ logins.
15
+ - An application's users are exactly the Lamaste users **entitled** to it.
16
+ The application may keep a local user table — and should, for joins and
17
+ history — but that table is a **projection** of Lamaste's directory, kept
18
+ current by Lamaste, never edited as the source of truth.
19
+ - When a person or an entitlement changes, Lamaste **notifies** the
20
+ application; the application pulls the change and applies it however suits
21
+ its data. A periodic reconciliation catches anything a notification missed.
22
+ - On every request, the application knows **who** is calling, from an
23
+ assertion it can verify — not from a header it has to take on faith.
24
+
25
+ PDKS is the case that shapes this: its screens show employees' names on every
26
+ row. It cannot ask Lamaste for each one; it keeps its own copy and needs that
27
+ copy to follow Lamaste.
28
+
29
+ ## What exists today
30
+
31
+ | Piece | State |
32
+ |---|---|
33
+ | User store | Authelia's `users.yml`, keyed by **username**. No stable id. |
34
+ | `GET /api/identity/users`, `/users/:username`, `/groups` | Read `users.yml`; admin or agent with `identity:query`. |
35
+ | `GET /api/identity/self` | Echoes `Remote-*` headers; only meaningful behind Authelia. |
36
+ | SDK `IdentityClient` | `listUsers`, `getUser`, `listGroups` over mTLS. |
37
+ | SDK `parseIdentity` + Fastify plugin | Parses `Remote-User/Name/Email/Groups` headers. |
38
+ | Tunnel vhosts | **Blank** `Remote-User` and `Remote-Groups` before proxying — apps receive no identity. |
39
+ | Entitlements | Two models: per-plugin grants (`user-plugin-access.json`, by username) and Gatekeeper grants (groups → resources). |
40
+
41
+ So the SDK can list users, but nothing gives an application a stable key, a
42
+ scoped user list, a change feed, or a trustworthy identity on a request.
43
+
44
+ ## Model
45
+
46
+ ### Principals
47
+
48
+ Every user gets an immutable **`userId`** (UUID v4) at creation. Applications
49
+ key everything on `userId`; `username` is a mutable attribute (renames are
50
+ supported and propagate as an update).
51
+
52
+ The directory record Lamaste owns:
53
+
54
+ ```ts
55
+ interface DirectoryUser {
56
+ userId: string; // UUID, never reused
57
+ username: string; // login name, unique, may change
58
+ displayName: string;
59
+ email: string;
60
+ groups: string[]; // Lamaste groups (Gatekeeper tier), not raw Authelia groups
61
+ status: 'active' | 'disabled';
62
+ canSignIn: boolean; // false = directory-only person (see "People who never sign in")
63
+ updatedAt: string;
64
+ version: number; // per-record, increments on every change
65
+ }
66
+ ```
67
+
68
+ Lamaste stores the directory in its SQLite state (`userId`, attributes,
69
+ version), and keeps `users.yml` as the **derived** credential file Authelia
70
+ reads — Lamaste writes it, never parses it as the source of truth.
71
+
72
+ ### Entitlements
73
+
74
+ An **entitlement** says a user may use a plugin, optionally with roles:
75
+
76
+ ```ts
77
+ interface Entitlement {
78
+ plugin: string; // "pdks", "apsiyon", …
79
+ userId: string;
80
+ roles: string[]; // plugin-declared roles, e.g. ["viewer"], ["manager"]
81
+ source: 'direct' | `group:${string}`;
82
+ }
83
+ ```
84
+
85
+ Entitlements are computed from Gatekeeper grants (user or group → plugin
86
+ resource, with roles as the grant's context). The older
87
+ `user-plugin-access.json` model is migrated into Gatekeeper grants, so there
88
+ is **one** entitlement model.
89
+
90
+ Plugins declare their roles in the manifest:
91
+
92
+ ```jsonc
93
+ "identity": {
94
+ "roles": [
95
+ { "id": "viewer", "label": "Görüntüleyici" },
96
+ { "id": "manager", "label": "Yönetici" }
97
+ ],
98
+ "attributes": ["displayName", "email"] // what the plugin is allowed to receive; default displayName only
99
+ }
100
+ ```
101
+
102
+ `attributes` is data minimisation: a plugin receives only the fields it
103
+ declared, and the admin sees them at install.
104
+
105
+ ### What a plugin sees
106
+
107
+ A plugin's directory view is its **audience**: the entitled users, each with
108
+ only the declared attributes and the plugin's roles.
109
+
110
+ ```ts
111
+ interface AudienceMember {
112
+ userId: string;
113
+ username: string;
114
+ displayName?: string; // only if declared
115
+ email?: string; // only if declared
116
+ roles: string[]; // this plugin's roles only
117
+ status: 'active' | 'disabled';
118
+ version: number;
119
+ }
120
+ ```
121
+
122
+ Groups are not passed through raw — a plugin sees its roles, not the
123
+ organisation's group structure.
124
+
125
+ ## Change feed
126
+
127
+ ### Sequence
128
+
129
+ Every directory or entitlement change appends to a store-wide, monotonically
130
+ increasing **`seq`**, stamped in the same transaction as the change (the
131
+ pattern already proven in PDKS's sync: triggers stamp `seq`, a cursor on
132
+ `seq` also catches rewritten rows). The directory has a **`directoryId`**
133
+ that changes if the store is replaced, telling a subscriber its cursor is
134
+ void.
135
+
136
+ ### Pull (the source of truth for subscribers)
137
+
138
+ ```
139
+ GET /api/identity/audience?plugin=pdks → { directoryId, seq, members: [...] } full snapshot
140
+ GET /api/identity/audience/changes?plugin=pdks&after=<seq> → { directoryId, seq, changes: [...] } since a cursor
141
+ ```
142
+
143
+ Each change is the member's full new state or a tombstone:
144
+
145
+ ```ts
146
+ type AudienceChange =
147
+ | { seq: number; kind: 'upsert'; member: AudienceMember }
148
+ | { seq: number; kind: 'remove'; userId: string; reason: 'deleted' | 'unentitled' };
149
+ ```
150
+
151
+ Full state, not diffs, so a subscriber never has to reconstruct a record
152
+ from a chain of patches, and applying a change twice is harmless.
153
+ `changes` is paged (`limit`, default 500) and ordered by `seq`.
154
+
155
+ ### Push (a nudge, not the data)
156
+
157
+ Pushing data to subscribers would make Lamaste responsible for delivery and
158
+ ordering to machines behind CGNAT. Instead Lamaste pushes a **notification**
159
+ and the subscriber pulls:
160
+
161
+ - **Agent-hosted app plugins:** the server half calls the agent half's
162
+ `POST /admin/identity/notify { seq }` through the internal plugin tunnel
163
+ (the channel from the app-plugin design). The agent half pulls
164
+ `changes?after=<its cursor>`.
165
+ - **Server-hosted plugins:** an in-process event (`identity.changed`) on the
166
+ plugin host.
167
+ - **Plain agents** (no app plugin): their 30-second sync already contacts the
168
+ panel; the sync response carries the current `seq`, and the SDK pulls when
169
+ it moved.
170
+
171
+ A lost notification costs nothing: the next one, the next sync, or the
172
+ reconciliation carries the cursor forward.
173
+
174
+ ### Reconciliation
175
+
176
+ Daily, and at subscriber start-up, the SDK fetches the full audience snapshot
177
+ and compares it with the local projection (by `userId` and `version`). It
178
+ applies differences and logs how many it found — a non-zero count on a
179
+ healthy system is a bug in the change feed and is reported to the panel.
180
+
181
+ ## SDK (`@lamalibre/lamaste-identity@4.1.0`)
182
+
183
+ Today's surface stays; three pieces are added.
184
+
185
+ ### 1. `IdentitySubscriber` — keeps a local projection current
186
+
187
+ ```ts
188
+ const subscriber = new IdentitySubscriber({
189
+ plugin: 'pdks',
190
+ client, // existing IdentityClient (mTLS)
191
+ cursorStore, // where the subscriber keeps { directoryId, seq }
192
+ apply: async (changes, tx) => { // the plugin decides what to do with each change
193
+ for (const c of changes) {
194
+ if (c.kind === 'upsert') upsertPerson(tx, c.member);
195
+ else markRemoved(tx, c.userId, c.reason);
196
+ }
197
+ },
198
+ reconcileEvery: '24h',
199
+ });
200
+ await subscriber.start(); // initial snapshot or catch-up, then waits for notify()
201
+ subscriber.notify(seq); // called by the plugin's /admin/identity/notify route
202
+ ```
203
+
204
+ Guarantees:
205
+
206
+ - `apply` receives changes in `seq` order, in batches; the cursor advances
207
+ **only after `apply` resolves**, in the same transaction when the cursor
208
+ store is the plugin's own database (SQLite helper provided).
209
+ - One pull at a time per subscriber; notifications during a pull coalesce.
210
+ - A changed `directoryId` triggers a full snapshot instead of a catch-up.
211
+ - Retries with backoff on 5xx and network errors; a 403 (plugin no longer
212
+ allowed to query) stops the subscriber and reports it.
213
+
214
+ ### 2. `verifyIdentity` — who is calling, provably
215
+
216
+ Requests reach applications with a **signed identity assertion** instead of
217
+ bare `Remote-*` headers:
218
+
219
+ - **Through the app-plugin forwarder:** the server half adds the caller to
220
+ the caller assertion it already signs (app-plugin design, *Authentication
221
+ between the halves*).
222
+ - **Through a plain tunnel** (`authenticated` / `restricted`): Gatekeeper,
223
+ already the `auth_request` target, returns
224
+ `X-Lamalibre-Identity: <JWT>` on allow; nginx copies it to the upstream
225
+ request (`auth_request_set` + `proxy_set_header`) and still blanks every
226
+ client-supplied `Remote-*` and `X-Lamalibre-Identity` header first.
227
+ The JWT carries `sub` = `userId`, `username`, the declared attributes, the
228
+ plugin's roles, `aud` = the tunnel's plugin or FQDN, and a 60 s lifetime.
229
+ It is signed with a key Lamaste generates; applications verify it against
230
+ Lamaste's published key set.
231
+
232
+ ```ts
233
+ fastify.register(identityPlugin, { audience: 'pdks', keys: lamasteJwks });
234
+ // request.identity: { userId, username, displayName?, email?, roles } | null
235
+ ```
236
+
237
+ Header parsing (`parseIdentity`) stays for compatibility but is documented as
238
+ **not** a trust mechanism.
239
+
240
+ ### 3. Projection helpers
241
+
242
+ A small SQLite schema and helpers for the common case — a `lamaste_users`
243
+ table (`user_id PRIMARY KEY`, `username`, `display_name`, `email`, `roles`,
244
+ `status`, `version`, `removed_at`) plus the cursor row — so plugins that just
245
+ need names and roles do not each write their own. Plugins with their own
246
+ person model (PDKS) implement `apply` against their tables instead.
247
+
248
+ ## Removal and history
249
+
250
+ Removing a user's entitlement or the user does **not** delete history in
251
+ applications:
252
+
253
+ - `remove` marks the local row `removed_at` and stops the person from using
254
+ the application (their requests no longer carry an entitlement anyway).
255
+ - Historical rows keep pointing at the `userId`; the local row keeps the last
256
+ known name so reports still read "Ayşe Yılmaz", not a UUID.
257
+ - Erasure (a person's right to have data deleted) is a separate, explicit
258
+ operation: Lamaste emits `remove` with `reason: 'erased'`, and the
259
+ application must anonymise its rows for that `userId`. Each plugin
260
+ documents what erasure does to its data.
261
+
262
+ ## Employees, badges and offboarding
263
+
264
+ Decided 2026-10-13: **employees are Lamaste users.** There is one directory
265
+ for everyone who works on the site; nobody exists only inside an application.
266
+ Ending a person's access to everything — signing in, GitLab, printing at the
267
+ portal, doors — is **one action in Lamaste**: disable the user (or remove an
268
+ entitlement to end one thing).
269
+
270
+ ### Credentials a user holds
271
+
272
+ A user can hold several **credentials**, each of which proves "this is that
273
+ user" in a different place:
274
+
275
+ | Credential | Where it is presented | Verified by |
276
+ |---|---|---|
277
+ | Password + TOTP | Browser (Authelia) | Authelia |
278
+ | Badges (card ids) | Card readers at kiosks, the portal, doors | The site agent, from its local projection |
279
+ | Registry / API tokens | Terminals, machines | The service that issued them (e.g. Feria) |
280
+
281
+ Badges are managed in Lamaste on the user's page: enrol (scan or type the
282
+ id), label (e.g. "13.56 MHz", "125 kHz"), revoke. A person may hold several —
283
+ the taped 13.56 MHz + 125 kHz pair PDKS already handles is two badges on one
284
+ user. Badge ids are a user attribute plugins can declare (`"badges"`), so a
285
+ card-reading plugin receives them in its audience and nothing else does.
286
+
287
+ Disabling a user disables every credential at once. Revoking one badge (lost
288
+ card) leaves the others working.
289
+
290
+ ### Actions authorised by a badge
291
+
292
+ A card scan becomes an authenticated, authorised request:
293
+
294
+ ```
295
+ card read ─► site agent: badge id → userId (local projection)
296
+ ─► is the user active? does the user hold the role for this action?
297
+ (e.g. pdks-portal: "print", doors: "door:main-gate")
298
+ ─► allow / deny, locally
299
+ ─► audit event { userId, badgeId, action, result, at } — kept locally,
300
+ forwarded to Lamaste's audit log when online
301
+ ```
302
+
303
+ - **Authorisation is a role on an entitlement**, exactly like the web: the
304
+ portal plugin declares `print`, a door plugin declares one role per door or
305
+ door group. Granting "can print" is a Gatekeeper grant; so is revoking it.
306
+ - **The decision is made on site**, from the agent's projection, so it works
307
+ when the site's internet line is down (it has gone down before — see
308
+ `herd/docs/site-network-architecture.md`).
309
+ - **Every action is logged with the user**, not the card: "printed 3 labels —
310
+ Ayşe Yılmaz (badge 0006552424)". The badge id stays on the event because
311
+ when a card misbehaves, the operator needs to know which one was presented.
312
+
313
+ ### Revocation latency and offline sites
314
+
315
+ "One action ends everything" is only true once the change reaches each
316
+ place that enforces it:
317
+
318
+ | Enforcer | Reached by | Typical latency |
319
+ |---|---|---|
320
+ | Authelia (web sign-in) | Lamaste writes it directly | immediate |
321
+ | Tunnelled web apps | Gatekeeper, per request | immediate (within its cache lifetime) |
322
+ | Agent-hosted app plugins | Notify through the internal tunnel | seconds |
323
+ | Site agents (badges, doors) | Notify, else next 30 s sync | seconds; **unbounded while the site is offline** |
324
+ | GitLab | Connector (below) | seconds |
325
+
326
+ Offline sites are the hard case. A badge decision taken from a projection
327
+ that has not heard about a revocation will allow a dismissed employee in.
328
+ Each badge-reading plugin therefore declares an **offline policy**:
329
+
330
+ - `allow-cached` — keep deciding from the projection (printing, attendance:
331
+ low harm, and refusing would block the whole site).
332
+ - `fail-closed-after: <duration>` — deny once the projection has not
333
+ synchronised for longer than the duration (doors).
334
+
335
+ The agent reports projection age to the panel, so an offline site is visible.
336
+
337
+ ### Card security
338
+
339
+ Badge ids are identifiers, not secrets. The 125 kHz EM cards in use are
340
+ trivially cloneable, and plain 13.56 MHz UID reads are copyable too. That is
341
+ acceptable for attendance and printing, where a cloned card is logged under
342
+ a real person. **It is not acceptable for doors.** Doors need cards that prove
343
+ possession cryptographically (e.g. MIFARE DESFire EV2/EV3 with keys held by
344
+ the reader), which is a reader and card change, not a software one. Until
345
+ then a door plugin should treat a badge as one factor and accept it only for
346
+ low-risk doors.
347
+
348
+ ## Connectors: GitLab
349
+
350
+ GitLab is the first **connector** — a subscriber that projects the directory
351
+ into a system that has its own user database:
352
+
353
+ - **Sign-in:** GitLab signs users in through Lamaste's Authelia as an OpenID
354
+ Connect provider (GitLab's OmniAuth `openid_connect`), the same OIDC
355
+ provider the Feria design needs. Password sign-in on GitLab is turned off,
356
+ so a GitLab account cannot outlive the Lamaste user.
357
+ - **Provisioning:** an `IdentitySubscriber` for the `gitlab` plugin's
358
+ audience uses GitLab's admin API to create users (on first entitlement),
359
+ update name and email, **block** on `remove` (blocking keeps their issues,
360
+ comments and history intact), and unblock on re-entitlement. GitLab users
361
+ are matched to Lamaste by `userId`, stored in GitLab as the OIDC identity's
362
+ `extern_uid`.
363
+ - **Acting for a user from other applications:** a portal at a kiosk that
364
+ lets a badged-in employee comment on or close an issue calls GitLab's API
365
+ **as that user**, with an admin token and GitLab's `Sudo` header naming the
366
+ user. That token is the most powerful credential in the setup: it lives
367
+ only in the connector (an agent-hosted plugin), never on a kiosk; kiosks ask
368
+ the connector, which checks the caller's identity assertion and the
369
+ employee's entitlement before acting.
370
+
371
+ The same shape fits later systems: a door controller, a printer server, an
372
+ HR system — each is a subscriber with its own projection and its own idea of
373
+ "block".
374
+
375
+ **Feria** is one too. Its design (`feria/docs/design/networked-registry-and-login.md`)
376
+ asks Lamaste for a *group-change hook* that revokes or downgrades a person's
377
+ registry tokens when their `feria-*` membership changes. In this model that
378
+ hook is Feria's identity subscription: Feria declares the roles `reader`,
379
+ `publisher` and `admin`, entitlements grant them, and a `remove` or a role
380
+ change in Feria's audience triggers the revocation. One mechanism, not a
381
+ special hook per plugin.
382
+
383
+ ## External systems of record
384
+
385
+ PDKS's roster is currently pushed by samplepark.tr's personnel system
386
+ (`HERD_PDKS_SYNC`, `PUT /kiosk/pdks/roster`). With a Lamaste directory, the
387
+ arrows have to be decided once:
388
+
389
+ - **Lamaste is the source:** samplepark pushes people into Lamaste's
390
+ directory (a directory import API with the same full-snapshot semantics),
391
+ and PDKS follows Lamaste.
392
+ - **The external system stays the source** and Lamaste mirrors it — same
393
+ import API, run by a connector.
394
+
395
+ Either way PDKS stops being fed directly; there is one path into the
396
+ directory and one out of it.
397
+
398
+ ## Security
399
+
400
+ - Directory queries require the plugin's own certificate and the
401
+ `identity:query` capability **scoped to that plugin**: a plugin can read its
402
+ audience, never the whole directory.
403
+ - A plugin receives only the attributes it declared, and the admin approves
404
+ them at install.
405
+ - Identity assertions are signed (Ed25519), short-lived, audience-bound; the
406
+ signing key is 0600, owned by the Lamaste service account; rotation keeps
407
+ the previous key in the published set for one assertion lifetime.
408
+ - Client-supplied `Remote-*` and `X-Lamalibre-Identity` headers are always
409
+ removed at the relay before any value is set.
410
+ - The change feed never carries credentials — no passwords, no TOTP data, no
411
+ tokens.
412
+
413
+ ## Lamaste changes required
414
+
415
+ | # | Change |
416
+ |---|---|
417
+ | 1 | Directory store in SQLite with `userId`, versions, `seq` triggers, `directoryId`; `users.yml` becomes derived. Migration assigns ids to existing users. |
418
+ | 2 | One entitlement model: migrate `user-plugin-access.json` into Gatekeeper grants with roles. |
419
+ | 3 | `identity` block in the plugin manifest (roles, attributes). |
420
+ | 4 | Audience snapshot and change-feed endpoints, scoped per plugin. |
421
+ | 5 | Notification fan-out: app-plugin forwarder, in-process event, `seq` in agent sync responses. |
422
+ | 6 | Identity assertions: signing key, JWKS endpoint, Gatekeeper returns the assertion, nginx forwards it. |
423
+ | 7 | Bulk user import (CSV) and a directory import API for external systems of record. |
424
+ | 8 | SDK: `IdentitySubscriber`, `verifyIdentity` plugin, SQLite projection helpers. |
425
+ | 9 | Badges as user credentials: enrol, label, revoke; `badges` attribute for card-reading plugins. |
426
+ | 10 | Audit log: events forwarded by plugins and agents (`userId`, credential, action, result). |
427
+ | 11 | Projection age reported by agents; offline policy declared by badge-reading plugins. |
428
+ | 12 | Authelia OIDC provider (shared with Feria) and a GitLab connector plugin. |
429
+
430
+ ## Open questions
431
+
432
+ 1. *(Decided 2026-10-13: employees are Lamaste users; badges are credentials on the user.)*
433
+ 2. **PDKS's source of truth:** samplepark pushes into Lamaste, or Lamaste
434
+ mirrors samplepark?
435
+ 3. **Erasure:** which plugins hold data that must be anonymised on request,
436
+ and is a single `erased` event enough for them?
437
+ 4. **Door hardware:** which doors, and are their readers able to use
438
+ cryptographic cards (DESFire)? Until then, doors stay out of scope.
439
+ 5. **GitLab password sign-in:** turn it off once OIDC sign-in works (keeps
440
+ offboarding single-action), with the root account as the only exception?
441
+ 6. **Signing:** Gatekeeper signs assertions (it is already on the request
442
+ path), or the panel issues them and Gatekeeper only forwards — which keeps
443
+ Gatekeeper free of keys, as its design intends?
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamalibre/lamaste-docs",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "private": true,
5
5
  "description": "Lamaste documentation — markdown files served by the panel and published to GitHub Pages",
6
6
  "type": "module",
@@ -29,7 +29,7 @@ feria dev registry — OUT OF SCOPE, do not touch
29
29
 
30
30
  ### Naming Rules
31
31
 
32
- - **`lamaste`** is the core lib. You `import { ... } from '@lamalibre/lamaste@4.0.0'`. No `-lib` suffix.
32
+ - **`lamaste`** is the core lib. You `import { ... } from '@lamalibre/lamaste@4.1.0'`. No `-lib` suffix.
33
33
  - **`-d` suffix** for daemons — Unix convention (`httpd`, `sshd`, `mongod`). Instantly signals "long-running process".
34
34
  - **`-ui`** for React component libraries — universal term, avoids "serverd serves the panel" tautology.
35
35
  - **`create-*`** for all npx one-shot tools — the one npm convention everyone knows.
@@ -74,24 +74,24 @@ lamaste core library (types, constants, schemas, pl
74
74
 
75
75
  | # | Package (npm name) | Type | Depends on | Purpose |
76
76
  | --- | ----------------------------------- | ----------- | ---------- | ------------------------------------------------------------------------------------------------------------------- |
77
- | 1 | `@lamalibre/lamaste@4.0.0` | core lib | nothing | Types, constants, Zod schemas, plugin host Fastify plugin, atomic file helpers, client interfaces |
78
- | | | | | Subpath: `@lamalibre/lamaste@4.0.0/agent` — agent domain logic |
79
- | | | | | Subpath: `@lamalibre/lamaste@4.0.0/server` — server domain logic |
80
- | 2 | `@lamalibre/lamaste-agent@4.0.0` | CLI | `lamaste` | Thin agent CLI. No Fastify, no HTTP |
81
- | 3 | `@lamalibre/lamaste-agentd@4.0.0` | daemon | `lamaste` | Agent REST API on :9393. No CLI argument parsing |
82
- | 4 | `@lamalibre/lamaste-agent-ui@4.0.0` | UI lib | `lamaste` | Agent React components + browser SPA build. Used by desktop and agentd |
83
- | 5 | `@lamalibre/lamaste-server@4.0.0` | CLI | `lamaste` | **NEW.** Thin server CLI. No Fastify, no HTTP |
84
- | 6 | `@lamalibre/lamaste-serverd@4.0.0` | daemon | `lamaste` | Server REST API on :3100. No CLI argument parsing. Renamed from `serverd` |
85
- | 7 | `@lamalibre/lamaste-server-ui@4.0.0` | UI lib | `lamaste` | Server/admin React components + browser SPA build. Replaces both `panel-client` and `lamaste-admin-panel` |
86
- | 8 | `@lamalibre/lamaste@4.0.0-desktop` | desktop | `lamaste` | Unified Tauri app. Multi-target: full, agent-only, server-only. Credentials, tray, deep links |
87
- | 9 | `@lamalibre/lamaste-cloud@4.0.0` | lib + CLI | — | Cloud provider abstraction (already correct pattern) |
88
- | 10 | `@lamalibre/lamaste-tickets@4.0.0` | SDK | — | Agent-to-agent authorization |
89
- | 11 | `@lamalibre/lamaste-identity@4.0.0` | SDK | — | Authelia identity parsing and query |
90
- | 12 | `@lamalibre/create-lamaste@4.0.0` | provisioner | `lamaste` | Server provisioner: OS hardening, nginx, Authelia, certs. `npx create-lamaste` |
91
- | 13 | `@lamalibre/create-lamaste-agent@4.0.0` | provisioner | `lamaste` | Agent provisioner: cert enrollment, service setup. `npx create-lamaste-agent`. Renamed from `install-lamaste-agent` |
92
- | 14 | `@lamalibre/create-lamaste-desktop@4.0.0` | installer | — | Desktop app installer. `npx create-lamaste-desktop`. Renamed from `install-lamaste-desktop` |
93
- | 15 | `@lamalibre/create-lamaste-admin@4.0.0` | provisioner | `lamaste` | Admin cert upgrade to hardware-bound. `npx create-lamaste-admin`. Renamed from `install-lamaste-admin` |
94
- | 16 | `@lamalibre/create-lamaste-e2e@4.0.0` | test infra | — | E2E test MCP server. `npx create-lamaste-e2e`. Renamed from `install-lamaste-e2e-mcp` |
77
+ | 1 | `@lamalibre/lamaste@4.1.0` | core lib | nothing | Types, constants, Zod schemas, plugin host Fastify plugin, atomic file helpers, client interfaces |
78
+ | | | | | Subpath: `@lamalibre/lamaste@4.1.0/agent` — agent domain logic |
79
+ | | | | | Subpath: `@lamalibre/lamaste@4.1.0/server` — server domain logic |
80
+ | 2 | `@lamalibre/lamaste-agent@4.1.0` | CLI | `lamaste` | Thin agent CLI. No Fastify, no HTTP |
81
+ | 3 | `@lamalibre/lamaste-agentd@4.1.0` | daemon | `lamaste` | Agent REST API on :9393. No CLI argument parsing |
82
+ | 4 | `@lamalibre/lamaste-agent-ui@4.1.0` | UI lib | `lamaste` | Agent React components + browser SPA build. Used by desktop and agentd |
83
+ | 5 | `@lamalibre/lamaste-server@4.1.0` | CLI | `lamaste` | **NEW.** Thin server CLI. No Fastify, no HTTP |
84
+ | 6 | `@lamalibre/lamaste-serverd@4.1.0` | daemon | `lamaste` | Server REST API on :3100. No CLI argument parsing. Renamed from `serverd` |
85
+ | 7 | `@lamalibre/lamaste-server-ui@4.1.0` | UI lib | `lamaste` | Server/admin React components + browser SPA build. Replaces both `panel-client` and `lamaste-admin-panel` |
86
+ | 8 | `@lamalibre/lamaste@4.1.0-desktop` | desktop | `lamaste` | Unified Tauri app. Multi-target: full, agent-only, server-only. Credentials, tray, deep links |
87
+ | 9 | `@lamalibre/lamaste-cloud@4.1.0` | lib + CLI | — | Cloud provider abstraction (already correct pattern) |
88
+ | 10 | `@lamalibre/lamaste-tickets@4.1.0` | SDK | — | Agent-to-agent authorization |
89
+ | 11 | `@lamalibre/lamaste-identity@4.1.0` | SDK | — | Authelia identity parsing and query |
90
+ | 12 | `@lamalibre/create-lamaste@4.1.0` | provisioner | `lamaste` | Server provisioner: OS hardening, nginx, Authelia, certs. `npx create-lamaste` |
91
+ | 13 | `@lamalibre/create-lamaste-agent@4.1.0` | provisioner | `lamaste` | Agent provisioner: cert enrollment, service setup. `npx create-lamaste-agent`. Renamed from `install-lamaste-agent` |
92
+ | 14 | `@lamalibre/create-lamaste-desktop@4.1.0` | installer | — | Desktop app installer. `npx create-lamaste-desktop`. Renamed from `install-lamaste-desktop` |
93
+ | 15 | `@lamalibre/create-lamaste-admin@4.1.0` | provisioner | `lamaste` | Admin cert upgrade to hardware-bound. `npx create-lamaste-admin`. Renamed from `install-lamaste-admin` |
94
+ | 16 | `@lamalibre/create-lamaste-e2e@4.1.0` | test infra | — | E2E test MCP server. `npx create-lamaste-e2e`. Renamed from `install-lamaste-e2e-mcp` |
95
95
  | 17 | `@lamalibre/feria` | dev tool | — | Dev registry + release runner. **Out of scope — do not touch in this refactor** |
96
96
 
97
97
  ### Rename Map (1.x → 2.0.0)
@@ -116,7 +116,7 @@ The core library uses **subpath exports** to provide domain-specific entry point
116
116
 
117
117
  ```json
118
118
  {
119
- "name": "@lamalibre/lamaste@4.0.0",
119
+ "name": "@lamalibre/lamaste@4.1.0",
120
120
  "exports": {
121
121
  ".": "./dist/index.js",
122
122
  "./agent": "./dist/agent/index.js",
@@ -126,9 +126,9 @@ The core library uses **subpath exports** to provide domain-specific entry point
126
126
  ```
127
127
 
128
128
  ```typescript
129
- import { RESERVED_API_PREFIXES, pluginManifestSchema } from '@lamalibre/lamaste@4.0.0';
130
- import { loadRegistry, loadAgent, isAgentLoaded } from '@lamalibre/lamaste@4.0.0/agent';
131
- import { createTunnel, rotateCert } from '@lamalibre/lamaste@4.0.0/server';
129
+ import { RESERVED_API_PREFIXES, pluginManifestSchema } from '@lamalibre/lamaste@4.1.0';
130
+ import { loadRegistry, loadAgent, isAgentLoaded } from '@lamalibre/lamaste@4.1.0/agent';
131
+ import { createTunnel, rotateCert } from '@lamalibre/lamaste@4.1.0/server';
132
132
  ```
133
133
 
134
134
  **Why subpath exports instead of three packages (`lamaste-lib`, `lamaste-lib-agent`, `lamaste-lib-server`):**
@@ -149,7 +149,7 @@ import { createTunnel, rotateCert } from '@lamalibre/lamaste@4.0.0/server';
149
149
 
150
150
  | Layer | Agent side | Server side |
151
151
  | ------------------------ | ----------------------------------------------- | ---------------------------------------------- |
152
- | **Domain logic** | `@lamalibre/lamaste@4.0.0/agent` | `@lamalibre/lamaste@4.0.0/server` |
152
+ | **Domain logic** | `@lamalibre/lamaste@4.1.0/agent` | `@lamalibre/lamaste@4.1.0/server` |
153
153
  | **Operational CLI** | `lamaste-agent` | `lamaste-server` (new) |
154
154
  | **Long-running daemon** | `lamalibre-lamaste-agentd` on :9393 (extracted) | `lamalibre-lamaste-serverd` on :3100 (renamed) |
155
155
  | **UI components** | `lamaste-agent-ui` (renamed) | `lamaste-server-ui` (merged) |
@@ -471,7 +471,7 @@ The Rust backend has both `commands.rs` (legacy) and `agents.rs` (multi-agent).
471
471
 
472
472
  Create `packages/lamaste/` (TypeScript) with subpath exports:
473
473
 
474
- **Root (`@lamalibre/lamaste@4.0.0`):**
474
+ **Root (`@lamalibre/lamaste@4.1.0`):**
475
475
 
476
476
  - Types: `AdminClient`, `AgentClient` TypeScript interfaces, plugin manifest types, capability types
477
477
  - Constants: Reserved API prefixes, curated plugin list, capability names
@@ -479,7 +479,7 @@ Create `packages/lamaste/` (TypeScript) with subpath exports:
479
479
  - Plugin Host: Fastify plugin for plugin route mounting, bundle serving, disabled handler (unified from three routers)
480
480
  - File Helpers: Atomic write (temp → fsync → rename), promise-chain mutex
481
481
 
482
- **Agent subpath (`@lamalibre/lamaste@4.0.0/agent`):**
482
+ **Agent subpath (`@lamalibre/lamaste@4.1.0/agent`):**
483
483
 
484
484
  - Platform: Path helpers, platform detection (from `platform.js`)
485
485
  - Registry: Agent registry CRUD, label validation, legacy migration (from `registry.js`)
@@ -491,7 +491,7 @@ Create `packages/lamaste/` (TypeScript) with subpath exports:
491
491
  - Server Registry: servers.json and storage-servers.json CRUD (new — from Rust `cloud.rs`)
492
492
  - Mode: Server mode and admin cert file operations (new — from Rust `mode.rs`)
493
493
 
494
- **Server subpath (`@lamalibre/lamaste@4.0.0/server`):**
494
+ **Server subpath (`@lamalibre/lamaste@4.1.0/server`):**
495
495
 
496
496
  - Plugins: Server-side plugin lifecycle (uses shared schema from root)
497
497
  - Tunnels: Tunnel creation workflow with rollback (from `routes/tunnels.js`)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamalibre/lamaste-gatekeeper",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "description": "Authorization layer for Lamaste — groups, grants, and tunnel access control",
5
5
  "type": "module",
6
6
  "exports": {
@@ -58,12 +58,13 @@ ${b('COMMANDS')}
58
58
  ${c('status')} Server health, uptime, services, certificate status
59
59
  ${c('logs')} View server log output (journalctl wrapper)
60
60
  ${c('restart')} Restart panel server and optionally gatekeeper
61
- ${c('plugins')} Manage server-side plugins (list, install, enable, disable, uninstall)
61
+ ${c('plugins')} Manage server-side plugins (list, install, enable, disable, uninstall, oidc-clients, rotate-secret)
62
62
  ${c('tunnels')} Manage tunnels (list, create, delete, toggle)
63
63
  ${c('sites')} Manage static sites (list, create, delete)
64
64
  ${c('certs')} Certificate status, renewal, and agent cert listing
65
65
  ${c('reset-admin')} Emergency admin reset (reverts to P12, clears 2FA)
66
66
  ${c('chisel')} Manage chisel tunnel-server credentials (rotate-credential)
67
+ ${c('registry-credential')} Registry (Feria) service credential used to broker agent install tokens
67
68
  ${c('uninstall')} Full server teardown (requires confirmation)
68
69
 
69
70
  ${b('GLOBAL FLAGS')}
@@ -93,6 +94,9 @@ ${b('EXAMPLES')}
93
94
  ${d('# Install a plugin')}
94
95
  ${c('lamaste-server plugins install @lamalibre/herd-server')}
95
96
 
97
+ ${d("# Broker agent install tokens from the registry as the panel's own Authelia client")}
98
+ ${c('lamaste-server registry-credential set --url https://feria.example.com --oidc')}
99
+
96
100
  ${d('# List tunnels')}
97
101
  ${c('lamaste-server tunnels list')}
98
102
 
@@ -174,6 +178,11 @@ async function main() {
174
178
  await runResetAdmin({ json, passwordFile });
175
179
  break;
176
180
  }
181
+ case 'registry-credential': {
182
+ const { runRegistryCredential } = await import('../src/commands/registry-credential.js');
183
+ await runRegistryCredential(args.slice(1), { json });
184
+ break;
185
+ }
177
186
  case 'chisel': {
178
187
  const { runChisel } = await import('../src/commands/chisel.js');
179
188
  await runChisel(args.slice(1), { json });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamalibre/lamaste-server",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "description": "Server operations CLI for Lamaste",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -13,10 +13,11 @@
13
13
  "src"
14
14
  ],
15
15
  "scripts": {
16
- "lint": "echo 'No lint configured'"
16
+ "lint": "echo 'No lint configured'",
17
+ "test": "node --test test/*.test.js"
17
18
  },
18
19
  "dependencies": {
19
- "@lamalibre/lamaste": "^4.0.0",
20
+ "@lamalibre/lamaste": "^4.1.0",
20
21
  "chalk": "^5.3.0",
21
22
  "execa": "^9.0.0"
22
23
  },