@lamalibre/create-lamaste 4.4.0 → 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.
- package/package.json +2 -2
- package/scripts/lamaste-priv +200 -16
- package/src/index.js +1 -0
- package/src/lib/backend-cookie.js +65 -0
- package/src/lib/ip-panel-vhost.js +208 -0
- package/src/lib/service-config.js +5 -3
- package/src/tasks/nginx.js +18 -135
- package/src/tasks/redeploy.js +39 -1
- package/vendor/docs/00-introduction/how-it-works.md +2 -2
- package/vendor/docs/00-introduction/quickstart.md +5 -5
- package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
- package/vendor/docs/01-concepts/authentication.md +1 -1
- package/vendor/docs/01-concepts/authorization.md +66 -48
- package/vendor/docs/01-concepts/certificates.md +3 -3
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/security-model.md +70 -33
- package/vendor/docs/01-concepts/tickets.md +2 -2
- package/vendor/docs/01-concepts/tunneling.md +4 -4
- package/vendor/docs/02-guides/agent-setup.md +5 -5
- package/vendor/docs/02-guides/certificate-management.md +2 -2
- package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
- package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
- package/vendor/docs/02-guides/installation.md +3 -3
- package/vendor/docs/02-guides/managing-users.md +113 -25
- package/vendor/docs/02-guides/onboarding.md +2 -2
- package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
- package/vendor/docs/03-architecture/installer-flow.md +2 -2
- package/vendor/docs/03-architecture/installer.md +4 -4
- package/vendor/docs/03-architecture/management-flow.md +3 -1
- package/vendor/docs/03-architecture/nginx-configuration.md +165 -18
- package/vendor/docs/03-architecture/overview.md +13 -17
- package/vendor/docs/03-architecture/panel-client.md +6 -6
- package/vendor/docs/03-architecture/panel-server.md +3 -2
- package/vendor/docs/03-architecture/state-management.md +4 -8
- package/vendor/docs/03-architecture/system-overview.md +1 -1
- package/vendor/docs/04-api-reference/agent-hosted-plugins.md +292 -23
- package/vendor/docs/04-api-reference/app-host.md +3 -3
- package/vendor/docs/04-api-reference/gatekeeper.md +60 -5
- package/vendor/docs/04-api-reference/lifecycle-hooks.md +15 -13
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/registry-and-oidc.md +6 -4
- package/vendor/docs/04-api-reference/tunnels.md +67 -29
- package/vendor/docs/04-api-reference/users.md +241 -45
- package/vendor/docs/05-operations/backup-and-restore.md +35 -26
- package/vendor/docs/05-operations/monitoring.md +39 -0
- package/vendor/docs/05-operations/uninstalling.md +2 -1
- package/vendor/docs/05-operations/upgrades.md +15 -6
- package/vendor/docs/06-reference/config-files.md +83 -189
- package/vendor/docs/06-reference/glossary.md +3 -3
- package/vendor/docs/06-reference/installer-flags.md +13 -13
- package/vendor/docs/06-reference/ports-and-services.md +25 -26
- package/vendor/docs/06-reference/troubleshooting.md +8 -8
- package/vendor/docs/gatekeeper-design.md +10 -8
- package/vendor/docs/identity-directory-design.md +48 -14
- package/vendor/docs/package.json +1 -1
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
- package/vendor/gatekeeper/dist/index.d.ts +2 -1
- package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/index.js +2 -0
- package/vendor/gatekeeper/dist/index.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/grants.d.ts +6 -0
- package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/grants.js +6 -1
- package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/groups.d.ts +5 -0
- package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/groups.js +5 -1
- package/vendor/gatekeeper/dist/lib/groups.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/principals.d.ts +29 -0
- package/vendor/gatekeeper/dist/lib/principals.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/principals.js +67 -0
- package/vendor/gatekeeper/dist/lib/principals.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/tunnels.d.ts +13 -0
- package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/tunnels.js +68 -0
- package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/types.d.ts +19 -0
- package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.js +22 -48
- package/vendor/gatekeeper/dist/server/index.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts +15 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.js +250 -103
- package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js +61 -4
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/principals.d.ts +3 -0
- package/vendor/gatekeeper/dist/server/routes/principals.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/principals.js +27 -0
- package/vendor/gatekeeper/dist/server/routes/principals.js.map +1 -0
- package/vendor/gatekeeper/package.json +2 -2
- package/vendor/server/bin/lamaste-server.js +15 -1
- package/vendor/server/package.json +2 -2
- package/vendor/server/src/commands/plugin-identity.js +335 -0
- package/vendor/server/src/commands/plugins.js +99 -11
- package/vendor/server/src/commands/registry-credential.js +2 -1
- package/vendor/server/src/commands/tunnels.js +102 -2
- package/vendor/server/src/commands/uninstall.js +3 -1
- package/vendor/server/src/commands/users.js +358 -0
- package/vendor/server/src/plugin-approval.js +107 -5
- package/vendor/server/src/plugin-identity.js +382 -0
- package/vendor/server-ui/dist/assets/index-D-fer5ef.css +1 -0
- package/vendor/server-ui/dist/assets/index-DSaDPJaw.js +84 -0
- package/vendor/server-ui/dist/index.html +2 -2
- package/vendor/serverd/package.json +2 -2
- package/vendor/serverd/src/index.js +25 -1
- package/vendor/serverd/src/lib/audience.js +576 -0
- package/vendor/serverd/src/lib/authelia.js +78 -7
- package/vendor/serverd/src/lib/config.js +68 -43
- package/vendor/serverd/src/lib/directory.js +1113 -0
- package/vendor/serverd/src/lib/entitlements.js +517 -0
- package/vendor/serverd/src/lib/gatekeeper-client.js +215 -10
- package/vendor/serverd/src/lib/hook-pipelines.js +13 -0
- package/vendor/serverd/src/lib/identity-sync.js +905 -0
- package/vendor/serverd/src/lib/migrations/state/0013_identity_directory.sql +158 -0
- package/vendor/serverd/src/lib/migrations/state/0014_identity_ledger.sql +20 -0
- package/vendor/serverd/src/lib/mtls.js +6 -2
- package/vendor/serverd/src/lib/nginx.js +279 -48
- package/vendor/serverd/src/lib/plugin-hooks.js +98 -45
- package/vendor/serverd/src/lib/plugin-hosting.js +33 -5
- package/vendor/serverd/src/lib/plugin-lifecycle.js +100 -9
- package/vendor/serverd/src/lib/plugin-packages.js +30 -22
- package/vendor/serverd/src/lib/plugins.js +94 -15
- package/vendor/serverd/src/lib/registry-credential.js +46 -6
- package/vendor/serverd/src/lib/request-logging.js +19 -0
- package/vendor/serverd/src/lib/retained-data.js +50 -4
- package/vendor/serverd/src/lib/state.js +18 -0
- package/vendor/serverd/src/lib/tunnel-hooks.js +4 -1
- package/vendor/serverd/src/lib/user-access-session.js +21 -11
- package/vendor/serverd/src/lib/user-caller.js +67 -0
- package/vendor/serverd/src/lib/user-surface.js +733 -0
- package/vendor/serverd/src/lib/vhost-reconcile.js +136 -34
- package/vendor/serverd/src/middleware/user-access-session.js +9 -0
- package/vendor/serverd/src/routes/agent-forwarder.js +4 -2
- package/vendor/serverd/src/routes/invite.js +53 -12
- package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +107 -16
- package/vendor/serverd/src/routes/management/identity-directory.js +79 -0
- package/vendor/serverd/src/routes/management/invitations.js +78 -68
- package/vendor/serverd/src/routes/management/plugin-identity.js +221 -0
- package/vendor/serverd/src/routes/management/registry-credential.js +1 -1
- package/vendor/serverd/src/routes/management/tunnels.js +98 -2
- package/vendor/serverd/src/routes/management/users.js +290 -208
- package/vendor/serverd/src/routes/management.js +4 -0
- package/vendor/serverd/src/routes/onboarding/provision.js +14 -1
- package/vendor/serverd/src/routes/user-access.js +17 -4
- package/vendor/serverd/src/routes/user-surface.js +69 -0
- package/vendor/server-ui/dist/assets/index-25wo_8av.css +0 -1
- package/vendor/server-ui/dist/assets/index-CESyyK8V.js +0 -84
|
@@ -0,0 +1,905 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The identity feed — each plugin's audience (`lib/audience.js`) pushed to
|
|
3
|
+
* its agent half through the internal tunnel, which the agent half cannot
|
|
4
|
+
* reach the panel through (core `identity.ts`):
|
|
5
|
+
*
|
|
6
|
+
* POST /admin/identity/notify { protocol, directoryId, seq: head }
|
|
7
|
+
* → { protocol, directoryId, cursor } what the half holds
|
|
8
|
+
* POST /admin/identity/changes { protocol, directoryId, from, to, reset,
|
|
9
|
+
* reconcile, pass, final, changes: sealed }
|
|
10
|
+
*
|
|
11
|
+
* Lamaste's own calls (`callAgentHalf`: caller `lamaste`, the body hash
|
|
12
|
+
* bound, the answer authenticated by its responder signature over the
|
|
13
|
+
* body). A batch's `changes` are sealed to the request (core
|
|
14
|
+
* `sealIdentityChanges`: AES-256-GCM, a key derived from the installation
|
|
15
|
+
* secret, bound to the assertion's nonce and the envelope), so neither the
|
|
16
|
+
* chisel server nor anything holding a loopback end of the tunnel sees a
|
|
17
|
+
* member in clear. A half is *ready* for the installed version once it has
|
|
18
|
+
* applied everything up to the head — a sync that ends with its cursor at
|
|
19
|
+
* the head after at least one complete replay since it was hosted
|
|
20
|
+
* (`ready_version`): users reach its `/app` only then
|
|
21
|
+
* ({@link identityReadiness}), never with a half that answered the
|
|
22
|
+
* notification but is still behind. It gets what it lacks:
|
|
23
|
+
*
|
|
24
|
+
* - **incremental** — the rows stamped after its cursor, in batches of at
|
|
25
|
+
* most `IDENTITY_BATCH_MAX_CHANGES` changes and `IDENTITY_BATCH_MAX_BYTES`;
|
|
26
|
+
* - **replay from 0** — every row, the first batch `reset` from 0, the
|
|
27
|
+
* others stamping the same `pass` (`reconcile`), the last `final`: the
|
|
28
|
+
* half then marks every row the pass did not mention as removed. A replay
|
|
29
|
+
* is the snapshot: for a half that follows another directory (or none), a
|
|
30
|
+
* cursor ahead of the head, a half that has not completed one (the first
|
|
31
|
+
* sync since the plugin was installed or hosted, or after a replay was
|
|
32
|
+
* cut short), and the daily reconcile — every batch `reconcile`, the
|
|
33
|
+
* mismatches the half reports recorded and audited
|
|
34
|
+
* (`identity.reconcile.mismatches`) when there are any.
|
|
35
|
+
*
|
|
36
|
+
* When a hosting is removed, what the panel knew of its half goes with it
|
|
37
|
+
* ({@link forgetIdentityHalf}).
|
|
38
|
+
*
|
|
39
|
+
* A 409 `cursor_mismatch` restarts from the cursor the half reports (or
|
|
40
|
+
* replays, when it follows another directory). One sync per plugin at a
|
|
41
|
+
* time; requests made while one runs share the next. Preconditions —
|
|
42
|
+
* installed, declares `identity` (nothing identity is ever sent to a plugin
|
|
43
|
+
* that does not: a half without the routes answers an unsigned 404), its
|
|
44
|
+
* installed version approved, hosted, the hosting active and not down —
|
|
45
|
+
* are checked first, and a sync that cannot run records why. Triggers: a
|
|
46
|
+
* recompute that changed the plugin's rows, its half coming up
|
|
47
|
+
* (`tunnel-hooks.js`), a hosting or update that succeeded, approval, the
|
|
48
|
+
* panel's start, a minute's tick for a plugin that is behind or failed, the
|
|
49
|
+
* daily reconcile, and the manual route.
|
|
50
|
+
*
|
|
51
|
+
* Logs carry the plugin, counts and cursors — never a member's attributes.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import crypto from 'node:crypto';
|
|
55
|
+
import {
|
|
56
|
+
AGENT_HALF_IDENTITY_CHANGES_PATH,
|
|
57
|
+
AGENT_HALF_IDENTITY_NOTIFY_PATH,
|
|
58
|
+
AudienceChangeSchema,
|
|
59
|
+
IDENTITY_ANSWER_MAX_BYTES,
|
|
60
|
+
IDENTITY_BATCH_MAX_BYTES,
|
|
61
|
+
IDENTITY_BATCH_MAX_CHANGES,
|
|
62
|
+
IDENTITY_PROTOCOL_VERSION,
|
|
63
|
+
callAgentHalf,
|
|
64
|
+
parseIdentityChangesAnswer,
|
|
65
|
+
parseIdentityCursorConflict,
|
|
66
|
+
parseIdentityNotifyAnswer,
|
|
67
|
+
sealIdentityChanges,
|
|
68
|
+
} from '@lamalibre/lamaste';
|
|
69
|
+
import { pluginHostingPhase, signingSecretFor } from '@lamalibre/lamaste/server';
|
|
70
|
+
import { recordAuditBestEffort } from './audit.js';
|
|
71
|
+
import {
|
|
72
|
+
audienceHead,
|
|
73
|
+
audienceRowsAfter,
|
|
74
|
+
audiencesIdle,
|
|
75
|
+
audienceSummary,
|
|
76
|
+
recomputeAudiences,
|
|
77
|
+
} from './audience.js';
|
|
78
|
+
import { getConfig } from './config.js';
|
|
79
|
+
import { clearPendingPrincipals, getDirectoryId, reconcileDirectory } from './directory.js';
|
|
80
|
+
import { installedVersionApproved } from './plugin-hooks.js';
|
|
81
|
+
import { findPlugin, findPluginByRoute, readPlugins } from './plugins.js';
|
|
82
|
+
import { retainedIdentityPositions } from './retained-data.js';
|
|
83
|
+
import { getStateDb } from './state-db.js';
|
|
84
|
+
|
|
85
|
+
/** How long the half may take to answer a notification. */
|
|
86
|
+
const IDENTITY_NOTIFY_TIMEOUT_MS = 5_000;
|
|
87
|
+
|
|
88
|
+
/** How long the half may take to apply a batch (up to 1 MiB of changes). */
|
|
89
|
+
const IDENTITY_CHANGES_TIMEOUT_MS = 15_000;
|
|
90
|
+
|
|
91
|
+
/** The daily reconcile: a full replay at most this long after the last. */
|
|
92
|
+
const IDENTITY_RECONCILE_INTERVAL_MS = 24 * 60 * 60_000;
|
|
93
|
+
|
|
94
|
+
/** How often the panel reconciles the directory, recomputes the audiences and retries syncs. */
|
|
95
|
+
const IDENTITY_TICK_INTERVAL_MS = 60_000;
|
|
96
|
+
|
|
97
|
+
/** How many times one sync restarts after a 409 before it gives up until the next trigger. */
|
|
98
|
+
const SYNC_ATTEMPTS = 3;
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Sync state
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @typedef {object} IdentitySyncState
|
|
106
|
+
* @property {string | null} directoryId the directory the half follows, as it last answered
|
|
107
|
+
* @property {number | null} cursor the seq it has applied up to, as it last answered
|
|
108
|
+
* @property {string | null} readyVersion the plugin version whose half applied everything up to
|
|
109
|
+
* the head after a complete replay
|
|
110
|
+
* @property {string | null} lastSyncAt the last sync that left the half up to date
|
|
111
|
+
* @property {string | null} lastReconcileAt the last full replay
|
|
112
|
+
* @property {number | null} lastMismatches what the last daily reconcile found
|
|
113
|
+
* @property {string | null} lastError why the last sync did not finish, or was not tried
|
|
114
|
+
* @property {string} updatedAt
|
|
115
|
+
*/
|
|
116
|
+
|
|
117
|
+
function rowToState(row) {
|
|
118
|
+
if (!row) return null;
|
|
119
|
+
return {
|
|
120
|
+
directoryId: row.directory_id,
|
|
121
|
+
cursor: row.cursor === null ? null : Number(row.cursor),
|
|
122
|
+
readyVersion: row.ready_version,
|
|
123
|
+
lastSyncAt: row.last_sync_at,
|
|
124
|
+
lastReconcileAt: row.last_reconcile_at,
|
|
125
|
+
lastMismatches: row.last_mismatches === null ? null : Number(row.last_mismatches),
|
|
126
|
+
lastError: row.last_error,
|
|
127
|
+
updatedAt: row.updated_at,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* What the panel knows of plugin `name`'s feed, or null before its first
|
|
133
|
+
* sync.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} name
|
|
136
|
+
* @returns {Promise<IdentitySyncState | null>}
|
|
137
|
+
*/
|
|
138
|
+
export async function readIdentitySyncState(name) {
|
|
139
|
+
const db = await getStateDb();
|
|
140
|
+
return rowToState(db.prepare('SELECT * FROM plugin_identity_sync WHERE plugin = ?').get(name));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Merge `patch` into plugin `name`'s sync state. */
|
|
144
|
+
async function writeSyncState(name, patch) {
|
|
145
|
+
const db = await getStateDb();
|
|
146
|
+
const current = (await readIdentitySyncState(name)) ?? {
|
|
147
|
+
directoryId: null,
|
|
148
|
+
cursor: null,
|
|
149
|
+
readyVersion: null,
|
|
150
|
+
lastSyncAt: null,
|
|
151
|
+
lastReconcileAt: null,
|
|
152
|
+
lastMismatches: null,
|
|
153
|
+
lastError: null,
|
|
154
|
+
};
|
|
155
|
+
const next = { ...current, ...patch, updatedAt: new Date().toISOString() };
|
|
156
|
+
db.prepare(
|
|
157
|
+
`INSERT INTO plugin_identity_sync
|
|
158
|
+
(plugin, directory_id, cursor, ready_version, last_sync_at, last_reconcile_at,
|
|
159
|
+
last_mismatches, last_error, updated_at)
|
|
160
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
161
|
+
ON CONFLICT(plugin) DO UPDATE SET
|
|
162
|
+
directory_id = excluded.directory_id,
|
|
163
|
+
cursor = excluded.cursor,
|
|
164
|
+
ready_version = excluded.ready_version,
|
|
165
|
+
last_sync_at = excluded.last_sync_at,
|
|
166
|
+
last_reconcile_at = excluded.last_reconcile_at,
|
|
167
|
+
last_mismatches = excluded.last_mismatches,
|
|
168
|
+
last_error = excluded.last_error,
|
|
169
|
+
updated_at = excluded.updated_at`,
|
|
170
|
+
).run(
|
|
171
|
+
name,
|
|
172
|
+
next.directoryId,
|
|
173
|
+
next.cursor,
|
|
174
|
+
next.readyVersion,
|
|
175
|
+
next.lastSyncAt,
|
|
176
|
+
next.lastReconcileAt,
|
|
177
|
+
next.lastMismatches,
|
|
178
|
+
next.lastError,
|
|
179
|
+
next.updatedAt,
|
|
180
|
+
);
|
|
181
|
+
return next;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Forget what the panel knew of plugin `name`'s agent half: its hosting is
|
|
186
|
+
* gone. The half of a later hosting gets the whole audience (a replay) and
|
|
187
|
+
* is ready for users only once it has applied it. Where the removed half's
|
|
188
|
+
* projection stood went with its data, when an agent retains that
|
|
189
|
+
* (`recordRetainedData`), so the erasures owed there stay pending. The
|
|
190
|
+
* audience itself stays: it is the plugin's ledger, not the hosting's.
|
|
191
|
+
*
|
|
192
|
+
* @param {string} name
|
|
193
|
+
*/
|
|
194
|
+
export async function forgetIdentityHalf(name) {
|
|
195
|
+
const db = await getStateDb();
|
|
196
|
+
db.prepare('DELETE FROM plugin_identity_sync WHERE plugin = ?').run(name);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Readiness and pending erasures
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
/** The hosting installed plugin `plugin` owns, or null (see `ownHosting`). */
|
|
204
|
+
async function ownHostingOf(plugin) {
|
|
205
|
+
const { ownHosting } = await import('./plugin-hosting.js');
|
|
206
|
+
return ownHosting(plugin);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* @typedef {object} IdentityReadiness
|
|
211
|
+
* @property {boolean} ready users may reach the plugin's `/app`
|
|
212
|
+
* @property {boolean} declared the installed version declares `identity`
|
|
213
|
+
* @property {boolean} approved the installed version is approved
|
|
214
|
+
* @property {boolean} hostingActive its hosting is active
|
|
215
|
+
* @property {string | null} version the installed version
|
|
216
|
+
* @property {string | null} readyVersion the version whose half last applied everything up to
|
|
217
|
+
* the head after a complete replay
|
|
218
|
+
* @property {'not_installed' | 'no_identity' | 'not_approved' | 'not_hosted' | 'not_answered' | null} reason
|
|
219
|
+
*/
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Whether the plugin on `route` is ready for user callers: it declares
|
|
223
|
+
* `identity`, its installed version is approved, its hosting is active, and
|
|
224
|
+
* its agent half — since this version was installed — applied every change
|
|
225
|
+
* up to the head after a complete replay (answering protocol 1 alone is not
|
|
226
|
+
* enough: a half that is still behind would serve stale roles and names).
|
|
227
|
+
*
|
|
228
|
+
* @param {string} route
|
|
229
|
+
* @returns {Promise<IdentityReadiness>}
|
|
230
|
+
*/
|
|
231
|
+
export async function identityReadiness(route) {
|
|
232
|
+
return readinessOf(await findPluginByRoute(route));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** {@link identityReadiness} of an installed plugin's registry entry (null: not installed). */
|
|
236
|
+
async function readinessOf(plugin) {
|
|
237
|
+
if (!plugin?.identity) {
|
|
238
|
+
return {
|
|
239
|
+
ready: false,
|
|
240
|
+
declared: false,
|
|
241
|
+
approved: false,
|
|
242
|
+
hostingActive: false,
|
|
243
|
+
version: plugin?.version ?? null,
|
|
244
|
+
readyVersion: null,
|
|
245
|
+
reason: plugin ? 'no_identity' : 'not_installed',
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
// A version whose approval cannot even be judged (an agent half without
|
|
249
|
+
// its pinned integrity) is not approved.
|
|
250
|
+
const approved = await installedVersionApproved(plugin).catch(() => false);
|
|
251
|
+
const record = await ownHostingOf(plugin);
|
|
252
|
+
const hostingActive = record !== null && pluginHostingPhase(record) === 'active';
|
|
253
|
+
const state = await readIdentitySyncState(plugin.name);
|
|
254
|
+
const readyVersion = state?.readyVersion ?? null;
|
|
255
|
+
const reason = !approved
|
|
256
|
+
? 'not_approved'
|
|
257
|
+
: !hostingActive
|
|
258
|
+
? 'not_hosted'
|
|
259
|
+
: readyVersion !== plugin.version
|
|
260
|
+
? 'not_answered'
|
|
261
|
+
: null;
|
|
262
|
+
return {
|
|
263
|
+
ready: reason === null,
|
|
264
|
+
declared: true,
|
|
265
|
+
approved,
|
|
266
|
+
hostingActive,
|
|
267
|
+
version: plugin.version,
|
|
268
|
+
readyVersion,
|
|
269
|
+
reason,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The seq up to which the half holds this directory's feed (0 when it follows another, or none). */
|
|
274
|
+
function acknowledgedCursor(state, directoryId) {
|
|
275
|
+
return state && state.directoryId === directoryId && state.cursor !== null ? state.cursor : 0;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The erasures plugin `name` has not acknowledged — wherever its data is:
|
|
280
|
+
* while it is installed, the `remove/erased` rows of its ledger stamped
|
|
281
|
+
* after the cursor its hosting's agent half last answered (every one when
|
|
282
|
+
* no half has: the next hosting's half is sent them); for every agent that
|
|
283
|
+
* retains data of it, those stamped after the position recorded with that
|
|
284
|
+
* data (`retained-data.js`; every one when none was). User ids in `seq`
|
|
285
|
+
* order, each once. An uninstalled plugin no agent keeps data of owes
|
|
286
|
+
* nothing.
|
|
287
|
+
*
|
|
288
|
+
* @param {string} name
|
|
289
|
+
* @returns {Promise<string[]>}
|
|
290
|
+
*/
|
|
291
|
+
export async function pendingErasures(name) {
|
|
292
|
+
const directoryId = await getDirectoryId();
|
|
293
|
+
const cursors = [];
|
|
294
|
+
if (await findPlugin(name)) {
|
|
295
|
+
cursors.push(acknowledgedCursor(await readIdentitySyncState(name), directoryId));
|
|
296
|
+
}
|
|
297
|
+
for (const position of await retainedIdentityPositions(name)) {
|
|
298
|
+
cursors.push(
|
|
299
|
+
position.directoryId === directoryId && position.cursor !== null ? position.cursor : 0,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
if (cursors.length === 0) return [];
|
|
303
|
+
const db = await getStateDb();
|
|
304
|
+
return db
|
|
305
|
+
.prepare(
|
|
306
|
+
`SELECT user_id FROM plugin_audience
|
|
307
|
+
WHERE plugin = ? AND json_extract(change, '$.reason') = 'erased' AND seq > ?
|
|
308
|
+
ORDER BY seq`,
|
|
309
|
+
)
|
|
310
|
+
.all(name, Math.min(...cursors))
|
|
311
|
+
.map((row) => row.user_id);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* For every erased user some plugin has not acknowledged yet, those
|
|
316
|
+
* plugins: `{ plugin, inactive }` — `inactive` when the plugin is not
|
|
317
|
+
* installed or no longer declares identity, so the feed does not reach it:
|
|
318
|
+
* an agent keeps its data (retained, or a hosted half that no longer takes
|
|
319
|
+
* the feed), and the erasure reaches that data when the plugin comes back
|
|
320
|
+
* — or goes with it when the data is purged.
|
|
321
|
+
*
|
|
322
|
+
* @returns {Promise<Map<string, Array<{ plugin: string, inactive: boolean }>>>}
|
|
323
|
+
*/
|
|
324
|
+
export async function pendingErasuresByUser() {
|
|
325
|
+
const db = await getStateDb();
|
|
326
|
+
const plugins = db
|
|
327
|
+
.prepare(
|
|
328
|
+
`SELECT DISTINCT plugin FROM plugin_audience
|
|
329
|
+
WHERE json_extract(change, '$.reason') = 'erased' ORDER BY plugin`,
|
|
330
|
+
)
|
|
331
|
+
.all()
|
|
332
|
+
.map((row) => row.plugin);
|
|
333
|
+
const out = new Map();
|
|
334
|
+
for (const plugin of plugins) {
|
|
335
|
+
const inactive = !(await findPlugin(plugin))?.identity;
|
|
336
|
+
for (const userId of await pendingErasures(plugin)) {
|
|
337
|
+
out.set(userId, [...(out.get(userId) ?? []), { plugin, inactive }]);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Everything the panel shows about plugin `name`'s identity: the declared
|
|
345
|
+
* block, readiness, the feed's state (with the head and how far behind the
|
|
346
|
+
* half is), the audience counts and the erasures pending. Null when the
|
|
347
|
+
* plugin is not installed.
|
|
348
|
+
*
|
|
349
|
+
* @param {string} name
|
|
350
|
+
*/
|
|
351
|
+
export async function identityView(name) {
|
|
352
|
+
const plugin = await findPlugin(name);
|
|
353
|
+
if (!plugin) return null;
|
|
354
|
+
const readiness = await readinessOf(plugin);
|
|
355
|
+
if (!plugin.identity) {
|
|
356
|
+
// A plugin that stopped declaring identity may still owe erasures (its ledger).
|
|
357
|
+
return {
|
|
358
|
+
declared: null,
|
|
359
|
+
readiness,
|
|
360
|
+
sync: null,
|
|
361
|
+
audience: null,
|
|
362
|
+
pendingErasures: await pendingErasures(name),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
const directoryId = await getDirectoryId();
|
|
366
|
+
const state = await readIdentitySyncState(name);
|
|
367
|
+
const summary = await audienceSummary(name);
|
|
368
|
+
const cursor = acknowledgedCursor(state, directoryId);
|
|
369
|
+
return {
|
|
370
|
+
declared: plugin.identity,
|
|
371
|
+
readiness,
|
|
372
|
+
sync: {
|
|
373
|
+
directoryId,
|
|
374
|
+
halfDirectoryId: state?.directoryId ?? null,
|
|
375
|
+
cursor: state?.cursor ?? null,
|
|
376
|
+
head: summary.head,
|
|
377
|
+
behind: (await audienceRowsAfter(name, cursor)).length,
|
|
378
|
+
readyVersion: state?.readyVersion ?? null,
|
|
379
|
+
lastSyncAt: state?.lastSyncAt ?? null,
|
|
380
|
+
lastReconcileAt: state?.lastReconcileAt ?? null,
|
|
381
|
+
lastMismatches: state?.lastMismatches ?? null,
|
|
382
|
+
lastError: state?.lastError ?? null,
|
|
383
|
+
},
|
|
384
|
+
audience: {
|
|
385
|
+
active: summary.active,
|
|
386
|
+
disabled: summary.disabled,
|
|
387
|
+
removed: summary.removed,
|
|
388
|
+
erased: summary.erased,
|
|
389
|
+
},
|
|
390
|
+
pendingErasures: await pendingErasures(name),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
// One sync
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The bytes of a batch request at its largest without its ciphertext:
|
|
400
|
+
* numbers at their widest, flags `false`, the sealed changes' `v` and IV.
|
|
401
|
+
*/
|
|
402
|
+
function envelopeBytes(directoryId) {
|
|
403
|
+
return Buffer.byteLength(
|
|
404
|
+
JSON.stringify({
|
|
405
|
+
protocol: IDENTITY_PROTOCOL_VERSION,
|
|
406
|
+
directoryId,
|
|
407
|
+
from: Number.MAX_SAFE_INTEGER,
|
|
408
|
+
to: Number.MAX_SAFE_INTEGER,
|
|
409
|
+
reset: false,
|
|
410
|
+
reconcile: false,
|
|
411
|
+
pass: crypto.randomUUID(),
|
|
412
|
+
final: false,
|
|
413
|
+
changes: { v: 1, iv: 'A'.repeat(16), ct: '' },
|
|
414
|
+
}),
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* The largest JSON array of changes whose sealed request stays within
|
|
420
|
+
* `IDENTITY_BATCH_MAX_BYTES` (the ciphertext is base64url: four characters
|
|
421
|
+
* for every three bytes, the tag included).
|
|
422
|
+
*/
|
|
423
|
+
function maxChangesBytes(directoryId) {
|
|
424
|
+
const room = IDENTITY_BATCH_MAX_BYTES - envelopeBytes(directoryId);
|
|
425
|
+
// ceil((n + 16) * 4 / 3) <= room for this n (the 16 bytes: GCM's tag).
|
|
426
|
+
return Math.floor((room * 3) / 4) - 16;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The stored rows as the feed's changes, in batches of at most
|
|
431
|
+
* `IDENTITY_BATCH_MAX_CHANGES` changes whose sealed request stays within
|
|
432
|
+
* `IDENTITY_BATCH_MAX_BYTES`. Throws on a row that is not the contract.
|
|
433
|
+
*/
|
|
434
|
+
function batchesOf(rows, directoryId) {
|
|
435
|
+
const limit = maxChangesBytes(directoryId);
|
|
436
|
+
const batches = [];
|
|
437
|
+
let current = [];
|
|
438
|
+
// The array's brackets.
|
|
439
|
+
let size = 2;
|
|
440
|
+
for (const row of rows) {
|
|
441
|
+
const change = { seq: row.seq, ...row.change };
|
|
442
|
+
if (!AudienceChangeSchema.safeParse(change).success) {
|
|
443
|
+
const err = new Error(
|
|
444
|
+
`The audience holds a row that is not an identity change (user ${row.userId})`,
|
|
445
|
+
);
|
|
446
|
+
err.code = 'corrupt_row';
|
|
447
|
+
throw err;
|
|
448
|
+
}
|
|
449
|
+
// One more byte for the comma between changes.
|
|
450
|
+
const bytes = Buffer.byteLength(JSON.stringify(change)) + 1;
|
|
451
|
+
if (
|
|
452
|
+
current.length > 0 &&
|
|
453
|
+
(current.length >= IDENTITY_BATCH_MAX_CHANGES || size + bytes > limit)
|
|
454
|
+
) {
|
|
455
|
+
batches.push(current);
|
|
456
|
+
current = [];
|
|
457
|
+
size = 2;
|
|
458
|
+
}
|
|
459
|
+
current.push(change);
|
|
460
|
+
size += bytes;
|
|
461
|
+
}
|
|
462
|
+
if (current.length > 0) batches.push(current);
|
|
463
|
+
return batches;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** What an answer that is not the contract is called — never its body. */
|
|
467
|
+
function describeFailure(result) {
|
|
468
|
+
if (!result.ok) return `${result.code}: ${result.error}`;
|
|
469
|
+
if (result.status === 404) return 'the agent half does not take the identity feed (404)';
|
|
470
|
+
if (result.status === 400) {
|
|
471
|
+
const protocol = result.body?.protocol;
|
|
472
|
+
return protocol != null && protocol !== IDENTITY_PROTOCOL_VERSION
|
|
473
|
+
? `the agent half speaks identity protocol ${protocol}, not ${IDENTITY_PROTOCOL_VERSION}`
|
|
474
|
+
: 'the agent half refused the identity call (400)';
|
|
475
|
+
}
|
|
476
|
+
return `the agent half answered the identity call with status ${result.status}`;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Send `batches` from `from`: a replay (`replay`) resets with its first
|
|
481
|
+
* batch and stamps `pass` with every batch, the last `final`. Each batch's
|
|
482
|
+
* changes are sealed (`seal`) to the request that carries them. Returns
|
|
483
|
+
* `{ kind: 'done', cursor, mismatches }`, `{ kind: 'conflict', position }`
|
|
484
|
+
* (a 409 with what the half holds) or `{ kind: 'failed', error }`.
|
|
485
|
+
*/
|
|
486
|
+
async function sendBatches(
|
|
487
|
+
call,
|
|
488
|
+
seal,
|
|
489
|
+
{ name, directoryId, batches, from, replay, reconcile, logger },
|
|
490
|
+
) {
|
|
491
|
+
const pass = crypto.randomUUID();
|
|
492
|
+
let cursor = from;
|
|
493
|
+
let mismatches = 0;
|
|
494
|
+
// A replay with nothing to say still resets and ends the pass.
|
|
495
|
+
const list = batches.length > 0 ? batches : replay ? [[]] : [];
|
|
496
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
497
|
+
const changes = list[i];
|
|
498
|
+
const to = changes.length > 0 ? changes[changes.length - 1].seq : cursor;
|
|
499
|
+
const envelope = {
|
|
500
|
+
directoryId,
|
|
501
|
+
from: cursor,
|
|
502
|
+
to,
|
|
503
|
+
reset: replay && i === 0,
|
|
504
|
+
reconcile: replay && (reconcile || i > 0),
|
|
505
|
+
pass,
|
|
506
|
+
final: replay && i === list.length - 1,
|
|
507
|
+
};
|
|
508
|
+
const result = await call(
|
|
509
|
+
AGENT_HALF_IDENTITY_CHANGES_PATH,
|
|
510
|
+
// Sealed to the nonce of the very request that carries them.
|
|
511
|
+
(nonce) => ({
|
|
512
|
+
protocol: IDENTITY_PROTOCOL_VERSION,
|
|
513
|
+
...envelope,
|
|
514
|
+
changes: seal(nonce, envelope, changes),
|
|
515
|
+
}),
|
|
516
|
+
IDENTITY_CHANGES_TIMEOUT_MS,
|
|
517
|
+
);
|
|
518
|
+
if (result.ok && result.status === 409) {
|
|
519
|
+
const conflict = parseIdentityCursorConflict(result.body);
|
|
520
|
+
if (!conflict)
|
|
521
|
+
return { kind: 'failed', error: 'the agent half answered 409 in an unexpected shape' };
|
|
522
|
+
return {
|
|
523
|
+
kind: 'conflict',
|
|
524
|
+
position: { directoryId: conflict.directoryId, cursor: conflict.cursor },
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
if (!result.ok || result.status !== 200) {
|
|
528
|
+
return { kind: 'failed', error: describeFailure(result) };
|
|
529
|
+
}
|
|
530
|
+
const answer = parseIdentityChangesAnswer(result.body);
|
|
531
|
+
if (!answer || answer.directoryId !== directoryId || answer.cursor !== to) {
|
|
532
|
+
return { kind: 'failed', error: 'the agent half answered a batch with another cursor' };
|
|
533
|
+
}
|
|
534
|
+
cursor = to;
|
|
535
|
+
mismatches += answer.mismatches ?? 0;
|
|
536
|
+
await writeSyncState(name, { directoryId, cursor });
|
|
537
|
+
logger?.info(
|
|
538
|
+
{ plugin: name, from: envelope.from, to, changes: changes.length, replay, reconcile },
|
|
539
|
+
'Sent an identity batch',
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
return { kind: 'done', cursor, mismatches };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* @typedef {object} IdentitySyncResult
|
|
547
|
+
* @property {'synced' | 'up-to-date' | 'skipped' | 'failed'} status
|
|
548
|
+
* @property {string} [reason] why it was skipped
|
|
549
|
+
* @property {string} [error] why it failed (or was skipped)
|
|
550
|
+
* @property {number} [cursor] where the half is now
|
|
551
|
+
* @property {boolean} [replay] whether everything was sent from 0
|
|
552
|
+
* @property {number} [mismatches] what the reconcile found
|
|
553
|
+
*/
|
|
554
|
+
|
|
555
|
+
/** Record why a sync did not run — written only when the reason changed. */
|
|
556
|
+
async function recordSkip(name, reason, message, logger) {
|
|
557
|
+
const state = await readIdentitySyncState(name);
|
|
558
|
+
if (state?.lastError !== message) {
|
|
559
|
+
await writeSyncState(name, { lastError: message });
|
|
560
|
+
logger?.info({ plugin: name, reason }, 'Identity sync not run');
|
|
561
|
+
}
|
|
562
|
+
return { status: 'skipped', reason, error: message };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function recordFailure(name, error, logger) {
|
|
566
|
+
await writeSyncState(name, { lastError: error });
|
|
567
|
+
logger?.warn({ plugin: name, error }, 'Identity sync failed');
|
|
568
|
+
return { status: 'failed', error };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** One sync of plugin `name` (see the module comment). */
|
|
572
|
+
async function syncOnce(name, { reconcile, logger }) {
|
|
573
|
+
const plugin = await findPlugin(name);
|
|
574
|
+
if (!plugin) return { status: 'skipped', reason: 'not_installed', error: 'not installed' };
|
|
575
|
+
// Nothing identity is ever sent to a plugin that does not declare it.
|
|
576
|
+
if (!plugin.identity) {
|
|
577
|
+
return { status: 'skipped', reason: 'no_identity', error: 'declares no identity' };
|
|
578
|
+
}
|
|
579
|
+
if (!(await installedVersionApproved(plugin))) {
|
|
580
|
+
return recordSkip(
|
|
581
|
+
name,
|
|
582
|
+
'not_approved',
|
|
583
|
+
`Version ${plugin.version} waits for approval — its users are not sent until it is approved`,
|
|
584
|
+
logger,
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
const record = await ownHostingOf(plugin);
|
|
588
|
+
if (!record) {
|
|
589
|
+
return recordSkip(name, 'not_hosted', 'The plugin is not hosted on an agent', logger);
|
|
590
|
+
}
|
|
591
|
+
const phase = pluginHostingPhase(record);
|
|
592
|
+
if (phase !== 'active') {
|
|
593
|
+
return recordSkip(name, 'hosting_not_active', `The hosting is ${phase}`, logger);
|
|
594
|
+
}
|
|
595
|
+
if (record.health?.state === 'down') {
|
|
596
|
+
return recordSkip(
|
|
597
|
+
name,
|
|
598
|
+
'down',
|
|
599
|
+
`The agent half is down (${record.health.reason ?? 'unknown'}) since ${record.health.since ?? 'unknown'}`,
|
|
600
|
+
logger,
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const directoryId = await getDirectoryId();
|
|
605
|
+
const before = await readIdentitySyncState(name);
|
|
606
|
+
// One secret for the whole sync: requests are signed and changes sealed with it.
|
|
607
|
+
const secret = signingSecretFor(record);
|
|
608
|
+
const call = (path, body, timeoutMs) =>
|
|
609
|
+
callAgentHalf({
|
|
610
|
+
plugin: record.plugin,
|
|
611
|
+
port: record.port,
|
|
612
|
+
secret,
|
|
613
|
+
method: 'POST',
|
|
614
|
+
path,
|
|
615
|
+
body,
|
|
616
|
+
timeoutMs,
|
|
617
|
+
maxResponseBytes: IDENTITY_ANSWER_MAX_BYTES,
|
|
618
|
+
});
|
|
619
|
+
const seal = (nonce, envelope, changes) =>
|
|
620
|
+
sealIdentityChanges({ secret, plugin: record.plugin, nonce, envelope, changes });
|
|
621
|
+
|
|
622
|
+
const head = await audienceHead(name);
|
|
623
|
+
const notified = await call(
|
|
624
|
+
AGENT_HALF_IDENTITY_NOTIFY_PATH,
|
|
625
|
+
{ protocol: IDENTITY_PROTOCOL_VERSION, directoryId, seq: head },
|
|
626
|
+
IDENTITY_NOTIFY_TIMEOUT_MS,
|
|
627
|
+
);
|
|
628
|
+
if (!notified.ok || notified.status !== 200) {
|
|
629
|
+
return recordFailure(name, describeFailure(notified), logger);
|
|
630
|
+
}
|
|
631
|
+
const answer = parseIdentityNotifyAnswer(notified.body);
|
|
632
|
+
if (!answer) {
|
|
633
|
+
return recordFailure(
|
|
634
|
+
name,
|
|
635
|
+
'the agent half answered the notification in an unexpected shape',
|
|
636
|
+
logger,
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
// What the half holds. It is not ready for user callers yet: that waits
|
|
640
|
+
// until it has applied everything up to the head (below).
|
|
641
|
+
await writeSyncState(name, { directoryId: answer.directoryId, cursor: answer.cursor });
|
|
642
|
+
|
|
643
|
+
// A plugin that has not completed a full replay since it was installed
|
|
644
|
+
// (its first sync), or since a replay was cut short, replays — whatever
|
|
645
|
+
// its half holds was not built from this installation's audience.
|
|
646
|
+
const replayOwed = !before?.lastReconcileAt;
|
|
647
|
+
let position = { directoryId: answer.directoryId, cursor: answer.cursor };
|
|
648
|
+
for (let attempt = 0; attempt < SYNC_ATTEMPTS; attempt += 1) {
|
|
649
|
+
const rows = await audienceRowsAfter(name, 0);
|
|
650
|
+
const currentHead = rows.length > 0 ? rows[rows.length - 1].seq : 0;
|
|
651
|
+
const replay =
|
|
652
|
+
reconcile ||
|
|
653
|
+
replayOwed ||
|
|
654
|
+
position.directoryId !== directoryId ||
|
|
655
|
+
position.cursor > currentHead;
|
|
656
|
+
if (!replay && position.cursor === currentHead) {
|
|
657
|
+
// At the head, a complete replay behind it: ready for this version.
|
|
658
|
+
await writeSyncState(name, {
|
|
659
|
+
directoryId,
|
|
660
|
+
cursor: position.cursor,
|
|
661
|
+
readyVersion: plugin.version,
|
|
662
|
+
lastSyncAt: new Date().toISOString(),
|
|
663
|
+
lastError: null,
|
|
664
|
+
});
|
|
665
|
+
return { status: 'up-to-date', cursor: position.cursor, replay: false };
|
|
666
|
+
}
|
|
667
|
+
let batches;
|
|
668
|
+
try {
|
|
669
|
+
batches = batchesOf(
|
|
670
|
+
replay ? rows : rows.filter((row) => row.seq > position.cursor),
|
|
671
|
+
directoryId,
|
|
672
|
+
);
|
|
673
|
+
} catch (err) {
|
|
674
|
+
return recordFailure(name, err.message, logger);
|
|
675
|
+
}
|
|
676
|
+
// A replay cut short leaves a half that was reset but never told which
|
|
677
|
+
// rows the pass did not mention: until one completes, every sync replays.
|
|
678
|
+
if (replay) await writeSyncState(name, { lastReconcileAt: null });
|
|
679
|
+
const sent = await sendBatches(call, seal, {
|
|
680
|
+
name,
|
|
681
|
+
directoryId,
|
|
682
|
+
batches,
|
|
683
|
+
from: replay ? 0 : position.cursor,
|
|
684
|
+
replay,
|
|
685
|
+
reconcile,
|
|
686
|
+
logger,
|
|
687
|
+
});
|
|
688
|
+
if (sent.kind === 'failed') return recordFailure(name, sent.error, logger);
|
|
689
|
+
if (sent.kind === 'conflict') {
|
|
690
|
+
logger?.warn(
|
|
691
|
+
{
|
|
692
|
+
plugin: name,
|
|
693
|
+
cursor: sent.position.cursor,
|
|
694
|
+
sameDirectory: sent.position.directoryId === directoryId,
|
|
695
|
+
},
|
|
696
|
+
'The agent half holds another cursor — continuing from it',
|
|
697
|
+
);
|
|
698
|
+
position = sent.position;
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
const now = new Date().toISOString();
|
|
702
|
+
// Every row up to the head applied (after a complete replay, this one or
|
|
703
|
+
// an earlier one): ready for this version.
|
|
704
|
+
await writeSyncState(name, {
|
|
705
|
+
directoryId,
|
|
706
|
+
cursor: sent.cursor,
|
|
707
|
+
readyVersion: plugin.version,
|
|
708
|
+
lastSyncAt: now,
|
|
709
|
+
lastError: null,
|
|
710
|
+
...(replay
|
|
711
|
+
? { lastReconcileAt: now, lastMismatches: reconcile ? sent.mismatches : null }
|
|
712
|
+
: {}),
|
|
713
|
+
});
|
|
714
|
+
if (reconcile && sent.mismatches > 0) {
|
|
715
|
+
logger?.warn(
|
|
716
|
+
{ plugin: name, mismatches: sent.mismatches },
|
|
717
|
+
'The identity reconcile found the agent half’s projection out of step',
|
|
718
|
+
);
|
|
719
|
+
await recordAuditBestEffort(
|
|
720
|
+
{
|
|
721
|
+
actor: 'lamaste',
|
|
722
|
+
action: 'identity.reconcile.mismatches',
|
|
723
|
+
plugin: name,
|
|
724
|
+
details: { mismatches: sent.mismatches, directoryId },
|
|
725
|
+
},
|
|
726
|
+
logger,
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
return {
|
|
730
|
+
status: 'synced',
|
|
731
|
+
cursor: sent.cursor,
|
|
732
|
+
replay,
|
|
733
|
+
...(reconcile ? { mismatches: sent.mismatches } : {}),
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
return recordFailure(
|
|
737
|
+
name,
|
|
738
|
+
`the agent half answered ${SYNC_ATTEMPTS} batches with another cursor — will try again`,
|
|
739
|
+
logger,
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// ---------------------------------------------------------------------------
|
|
744
|
+
// One sync per plugin at a time
|
|
745
|
+
// ---------------------------------------------------------------------------
|
|
746
|
+
|
|
747
|
+
/** plugin → { tail, queued: { reconcile, promise } | null } */
|
|
748
|
+
const syncs = new Map();
|
|
749
|
+
/** The logger of the latest request — background syncs log through it. */
|
|
750
|
+
let lastLogger = null;
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Sync plugin `name`'s audience to its agent half. One sync per plugin at a
|
|
754
|
+
* time: a request made while one runs waits for the next, which every
|
|
755
|
+
* request made meanwhile shares (a `reconcile` among them makes it a
|
|
756
|
+
* reconcile). Never rejects.
|
|
757
|
+
*
|
|
758
|
+
* @param {string} name
|
|
759
|
+
* @param {{ reconcile?: boolean, logger?: import('pino').Logger }} [opts]
|
|
760
|
+
* @returns {Promise<IdentitySyncResult>}
|
|
761
|
+
*/
|
|
762
|
+
export function syncPluginIdentity(name, { reconcile = false, logger } = {}) {
|
|
763
|
+
if (logger) lastLogger = logger;
|
|
764
|
+
let entry = syncs.get(name);
|
|
765
|
+
if (!entry) {
|
|
766
|
+
entry = { tail: Promise.resolve(), queued: null };
|
|
767
|
+
syncs.set(name, entry);
|
|
768
|
+
}
|
|
769
|
+
if (entry.queued) {
|
|
770
|
+
entry.queued.reconcile = entry.queued.reconcile || reconcile;
|
|
771
|
+
return entry.queued.promise;
|
|
772
|
+
}
|
|
773
|
+
const queued = { reconcile, promise: null };
|
|
774
|
+
queued.promise = entry.tail.then(async () => {
|
|
775
|
+
entry.queued = null;
|
|
776
|
+
try {
|
|
777
|
+
return await syncOnce(name, { reconcile: queued.reconcile, logger: lastLogger });
|
|
778
|
+
} catch (err) {
|
|
779
|
+
lastLogger?.error({ plugin: name, err: err.message }, 'Identity sync failed unexpectedly');
|
|
780
|
+
return { status: 'failed', error: err.message };
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
entry.queued = queued;
|
|
784
|
+
const tail = queued.promise.then(() => {
|
|
785
|
+
if (entry.tail === tail && entry.queued === null) syncs.delete(name);
|
|
786
|
+
});
|
|
787
|
+
entry.tail = tail;
|
|
788
|
+
return queued.promise;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* After the plugin's identity may have changed (approval, a hosting, an
|
|
793
|
+
* update or its revert): once its installed version is approved, drop from
|
|
794
|
+
* its role grants every role that version does not declare (a role a
|
|
795
|
+
* manifest removed stays removed — `pruneUndeclaredRoles`); recompute the
|
|
796
|
+
* audiences; then sync the plugin when it declares identity. Never throws;
|
|
797
|
+
* null when nothing was synced.
|
|
798
|
+
*
|
|
799
|
+
* @param {string} name
|
|
800
|
+
* @param {import('pino').Logger} [logger]
|
|
801
|
+
* @returns {Promise<IdentitySyncResult | null>}
|
|
802
|
+
*/
|
|
803
|
+
export async function refreshPluginIdentity(name, logger) {
|
|
804
|
+
let plugin = null;
|
|
805
|
+
try {
|
|
806
|
+
plugin = await findPlugin(name);
|
|
807
|
+
if (plugin && (await installedVersionApproved(plugin).catch(() => false))) {
|
|
808
|
+
const { pruneUndeclaredRoles } = await import('./entitlements.js');
|
|
809
|
+
await pruneUndeclaredRoles(plugin, { actor: 'lamaste', logger });
|
|
810
|
+
}
|
|
811
|
+
} catch (err) {
|
|
812
|
+
logger?.warn({ plugin: name, err: err.message }, 'Could not read the plugin to sync its users');
|
|
813
|
+
}
|
|
814
|
+
await recomputeAudiences({ logger });
|
|
815
|
+
return plugin?.identity ? syncPluginIdentity(name, { logger }) : null;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** Resolves once no recompute and no sync runs or waits (tests). */
|
|
819
|
+
export async function identityIdle() {
|
|
820
|
+
for (;;) {
|
|
821
|
+
await audiencesIdle();
|
|
822
|
+
const tails = [...syncs.values()].map((entry) => entry.tail);
|
|
823
|
+
if (tails.length === 0) return;
|
|
824
|
+
await Promise.all(tails);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// ---------------------------------------------------------------------------
|
|
829
|
+
// The minute's tick
|
|
830
|
+
// ---------------------------------------------------------------------------
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Sync every plugin that declares identity and is behind — no state yet,
|
|
834
|
+
* another directory or cursor than the head, a version that has not
|
|
835
|
+
* answered, or a last sync that did not finish — and reconcile those whose
|
|
836
|
+
* last full replay is a day old.
|
|
837
|
+
*/
|
|
838
|
+
async function syncDuePlugins(logger) {
|
|
839
|
+
const directoryId = await getDirectoryId();
|
|
840
|
+
const plugins = (await readPlugins()).plugins.filter((p) => p.identity);
|
|
841
|
+
const now = Date.now();
|
|
842
|
+
for (const plugin of plugins) {
|
|
843
|
+
const state = await readIdentitySyncState(plugin.name);
|
|
844
|
+
const reconcileDue =
|
|
845
|
+
state?.lastReconcileAt != null &&
|
|
846
|
+
now - Date.parse(state.lastReconcileAt) >= IDENTITY_RECONCILE_INTERVAL_MS;
|
|
847
|
+
const behind =
|
|
848
|
+
!state ||
|
|
849
|
+
state.directoryId !== directoryId ||
|
|
850
|
+
state.cursor !== (await audienceHead(plugin.name)) ||
|
|
851
|
+
state.readyVersion !== plugin.version ||
|
|
852
|
+
state.lastError !== null;
|
|
853
|
+
if (reconcileDue || behind) {
|
|
854
|
+
void syncPluginIdentity(plugin.name, { reconcile: reconcileDue, logger });
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
let tickTimer = null;
|
|
860
|
+
let ticking = false;
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* One tick: reconcile the directory with users.yml (once onboarding is
|
|
864
|
+
* done), clear the Gatekeeper principals of removed users still pending,
|
|
865
|
+
* recompute the audiences (catching Gatekeeper CLI edits), then start the
|
|
866
|
+
* syncs that are due. Never throws.
|
|
867
|
+
*/
|
|
868
|
+
export async function identityTick(logger) {
|
|
869
|
+
if (ticking) return;
|
|
870
|
+
ticking = true;
|
|
871
|
+
try {
|
|
872
|
+
if (getConfig().onboarding?.status === 'COMPLETED') {
|
|
873
|
+
try {
|
|
874
|
+
await reconcileDirectory({ actor: 'lamaste', logger });
|
|
875
|
+
} catch (err) {
|
|
876
|
+
logger?.warn({ err: err.message }, 'Could not reconcile the user directory');
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
await clearPendingPrincipals({ logger });
|
|
880
|
+
await recomputeAudiences({ logger });
|
|
881
|
+
await syncDuePlugins(logger);
|
|
882
|
+
} catch (err) {
|
|
883
|
+
logger?.error({ err: err.message }, 'Identity tick failed');
|
|
884
|
+
} finally {
|
|
885
|
+
ticking = false;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Start the identity services at panel start: one tick now (reconcile,
|
|
891
|
+
* recompute, the first syncs), then one every minute. Idempotent.
|
|
892
|
+
*
|
|
893
|
+
* @param {import('pino').Logger} logger
|
|
894
|
+
*/
|
|
895
|
+
export function startIdentityServices(logger) {
|
|
896
|
+
if (tickTimer) return;
|
|
897
|
+
void identityTick(logger);
|
|
898
|
+
tickTimer = setInterval(() => void identityTick(logger), IDENTITY_TICK_INTERVAL_MS);
|
|
899
|
+
tickTimer.unref();
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
export function stopIdentityServices() {
|
|
903
|
+
if (tickTimer) clearInterval(tickTimer);
|
|
904
|
+
tickTimer = null;
|
|
905
|
+
}
|