@cairnkeep/cli 2.23.1 → 2.25.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/CHANGELOG.md +51 -0
- package/docs/README.md +4 -0
- package/docs/agents.md +18 -0
- package/docs/containers.md +4 -0
- package/docs/design/team-mode.md +17 -9
- package/docs/harness-compatibility.md +17 -0
- package/docs/learning/CURRICULUM-MAP.md +3 -1
- package/docs/learning/FEATURE-GUIDE.md +34 -1
- package/docs/learning/README.md +5 -3
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +2 -2
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/learning/lessons/L04-memory-fundamentals.md +12 -0
- package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
- package/docs/learning/lessons/L21-context-packs.md +1 -1
- package/docs/learning/lessons/L22-native-windows.md +2 -2
- package/docs/learning/lessons/L23-guided-setup.md +1 -1
- package/docs/learning/lessons/L24-okf-exchange.md +1 -1
- package/docs/learning/lessons/L25-playbooks.md +1 -1
- package/docs/learning/lessons/L26-context-intelligence.md +1 -1
- package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
- package/docs/learning/lessons/L28-security-assurance.md +2 -2
- package/docs/learning/lessons/L29-team-continuity.md +95 -0
- package/docs/learning/tracks/operator.md +2 -0
- package/docs/learning/video-scripts/V04-memory-retrieval-supplement.md +13 -0
- package/docs/learning/video-scripts/V29-team-continuity.md +66 -0
- package/docs/mcp-tool-profiles.md +7 -0
- package/docs/memory-retrieval.md +84 -2
- package/docs/operating.md +22 -3
- package/docs/privacy-and-data-flow.md +37 -9
- package/docs/security-assurance.md +28 -0
- package/docs/storage.md +18 -0
- package/docs/team-pilot.md +132 -0
- package/docs/team.md +234 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/index.js +30 -2
- package/mcp-memory-server/dist/mcp-tool-catalog.js +4 -1
- package/mcp-memory-server/dist/memory-reranker.js +109 -0
- package/mcp-memory-server/dist/team-cli.js +218 -0
- package/mcp-memory-server/dist/team-http.js +193 -0
- package/mcp-memory-server/dist/team-mcp.js +82 -0
- package/mcp-memory-server/dist/team-store.js +663 -0
- package/mcp-memory-server/package.json +1 -0
- package/package.json +1 -1
- package/scripts/cairn-cli.mjs +2 -0
- package/scripts/completion.sh +23 -3
- package/scripts/doctor.sh +17 -0
- package/scripts/lib/team-retention.mjs +29 -0
- package/scripts/probe-memory-reranker.mjs +22 -0
- package/scripts/test-ci-sharding.sh +4 -0
- package/scripts/test-completion.sh +8 -0
- package/scripts/test-container-health.mjs +2 -0
- package/scripts/test-container.sh +21 -1
- package/scripts/test-dependency-security.mjs +11 -1
- package/scripts/test-doctor.sh +28 -0
- package/scripts/test-package-install.sh +9 -0
- package/scripts/test-team-docs.sh +52 -0
- package/scripts/test-team-pilot.mjs +59 -0
- package/scripts/test-windows-native.mjs +5 -0
- package/scripts/uninstall.sh +13 -0
- package/scripts/verify-dependency-security.mjs +3 -2
- package/scripts/verify-team-retention.mjs +3 -0
- package/scripts/windows-platform.mjs +14 -2
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { closeSync, constants, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, renameSync, rmSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, isAbsolute, join, parse, resolve } from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { hardenPrivatePath, privatePathIsSafe } from './platform-security.js';
|
|
6
|
+
const id = z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/);
|
|
7
|
+
const roles = z.array(z.enum(['reader', 'contributor', 'reviewer', 'maintainer', 'auditor'])).max(5);
|
|
8
|
+
const digest = z.string().regex(/^[a-f0-9]{64}$/);
|
|
9
|
+
const date = z.iso.datetime();
|
|
10
|
+
const memoryKey = z.string().regex(/^(decisions|patterns|constraints|pitfalls|conventions|bugs)\/[a-z0-9][a-z0-9-]{0,95}$/);
|
|
11
|
+
const text = z.string().min(1).refine(value => Buffer.byteLength(value, 'utf8') <= 64 * 1024 && !value.includes('\0') && Buffer.from(value, 'utf8').toString('utf8') === value);
|
|
12
|
+
const proposalInput = z.object({ request_id: z.string().uuid(), key: memoryKey, value: text, source_scope: z.enum(['selected-local', 'selected-project']), source_digest: digest, base_revision: digest.nullable(), expires_at: date, confirm: digest }).strict();
|
|
13
|
+
const proposalSchema = z.object({ schema_version: z.literal(1), id: z.string().uuid(), organization: id, project: id, subject: id, policy_digest: digest, created_at: date, input: proposalInput, digest }).strict();
|
|
14
|
+
const revisionSchema = z.object({ schema_version: z.literal(1), organization: id, project: id, key: memoryKey, value: text, proposal_id: z.string().uuid(), proposal_digest: digest, reviewer: id, source_scope: z.enum(['selected-local', 'selected-project']), source_digest: digest, base_revision: digest.nullable(), created_at: date, revision_digest: digest }).strict();
|
|
15
|
+
const reviewSchema = z.object({ proposal_id: z.string().uuid(), proposal_digest: digest, reviewer: id, decision: z.enum(['approve', 'reject']), revision_digest: digest.nullable(), at: date }).strict();
|
|
16
|
+
const credentialSchema = z.object({ id: z.string().uuid(), subject: id, credential_class: z.enum(['human', 'workload']), expires_at: date, revoked: z.boolean(), audience: z.literal('cairnkeep-team-v1') }).strict();
|
|
17
|
+
const projectSchema = z.object({ id, policy_revision: z.number().int().nonnegative() }).strict();
|
|
18
|
+
const memberSchema = z.object({ subject: id, roles }).strict();
|
|
19
|
+
const adminSchema = z.discriminatedUnion('operation', [
|
|
20
|
+
z.object({ operation: z.literal('project-create'), project: id }).strict(),
|
|
21
|
+
z.object({ operation: z.literal('member-set'), project: id, subject: id, roles }).strict(),
|
|
22
|
+
z.object({ operation: z.literal('credential-revoke'), credential: z.string().uuid() }).strict(),
|
|
23
|
+
z.object({ operation: z.literal('memory-delete'), project: id, key: memoryKey, confirm: digest }).strict(),
|
|
24
|
+
]);
|
|
25
|
+
const auditEntrySchema = z.object({ schema_version: z.literal(1), sequence: z.number().int().positive(), organization: id, project: id.or(z.literal('')), subject: id, credential_class: z.enum(['human', 'workload', 'local-os']), operation: z.enum(['project-create', 'member-set', 'credential-revoke', 'credential-issue', 'memory-delete', 'list', 'audit', 'read', 'history', 'search', 'propose', 'proposals', 'proposal-show', 'review', 'snapshot', 'restore']), result: z.enum(['accepted', 'denied', 'conflict', 'invalid']), object_digest: digest.nullable(), previous_digest: digest.nullable(), request_id: z.string().uuid(), at: date }).strict();
|
|
26
|
+
const snapshotSchema = z.object({ schema_version: z.literal(1), organization: id, records: z.array(z.object({ organization: id, project: id.or(z.literal('')), kind: z.enum(['project', 'member', 'credential', 'proposal', 'review', 'revision', 'head']), id: z.string().min(1).max(128), value: z.unknown() }).strict()).max(32768), audit: z.array(z.object({ sequence: z.number().int().positive(), organization: id, project: id.or(z.literal('')), value: z.string().max(4096), digest }).strict()).max(100000), digest }).strict();
|
|
27
|
+
const MAX_RECORD_BYTES = 16 * 1024 * 1024;
|
|
28
|
+
const MAX_AUDIT_BYTES = 16 * 1024 * 1024;
|
|
29
|
+
export const TEAM_SNAPSHOT_MAX_BYTES = 64 * 1024 * 1024;
|
|
30
|
+
const requestBase = { organization: id, project: id };
|
|
31
|
+
const requestSchema = z.discriminatedUnion('operation', [
|
|
32
|
+
z.object({ ...requestBase, operation: z.literal('list') }).strict(),
|
|
33
|
+
z.object({ ...requestBase, operation: z.literal('audit') }).strict(),
|
|
34
|
+
z.object({ ...requestBase, operation: z.literal('search'), query: z.string().min(1).max(256) }).strict(),
|
|
35
|
+
z.object({ ...requestBase, operation: z.literal('read'), key: memoryKey, revision: digest.optional() }).strict(),
|
|
36
|
+
z.object({ ...requestBase, operation: z.literal('history'), key: memoryKey }).strict(),
|
|
37
|
+
z.object({ ...requestBase, operation: z.literal('propose'), ...proposalInput.shape }).strict(),
|
|
38
|
+
z.object({ ...requestBase, operation: z.literal('proposals') }).strict(),
|
|
39
|
+
z.object({ ...requestBase, operation: z.literal('proposal-show'), proposal_id: z.string().uuid() }).strict(),
|
|
40
|
+
z.object({ ...requestBase, operation: z.literal('review'), proposal_id: z.string().uuid(), confirm: digest, decision: z.enum(['approve', 'reject']) }).strict(),
|
|
41
|
+
]);
|
|
42
|
+
// Every connection in this process shares a queue. Other processes synchronize
|
|
43
|
+
// with SQLite BEGIN IMMEDIATE, not a pathname lock or a read-then-write gap.
|
|
44
|
+
const queues = new Map();
|
|
45
|
+
async function immediate(db, operation) {
|
|
46
|
+
db.exec('BEGIN IMMEDIATE');
|
|
47
|
+
try {
|
|
48
|
+
const result = await operation();
|
|
49
|
+
db.exec('COMMIT');
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
db.exec('ROLLBACK');
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export class TeamError extends Error {
|
|
58
|
+
code;
|
|
59
|
+
constructor(code) {
|
|
60
|
+
super(`Team ${code}.`);
|
|
61
|
+
this.code = code;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function teamDigest(text) { return createHash('sha256').update(text).digest('hex'); }
|
|
65
|
+
function checkedProposal(input) {
|
|
66
|
+
const value = valid(proposalSchema, input);
|
|
67
|
+
const { digest: stored, ...body } = value;
|
|
68
|
+
if (teamDigest(JSON.stringify(body)) !== stored || value.id !== value.input.request_id || value.input.confirm !== teamDigest(value.input.value))
|
|
69
|
+
throw new TeamError('integrity');
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
function checkedRevision(input) {
|
|
73
|
+
const value = valid(revisionSchema, input);
|
|
74
|
+
const { revision_digest: stored, ...body } = value;
|
|
75
|
+
if (teamDigest(JSON.stringify(body)) !== stored)
|
|
76
|
+
throw new TeamError('integrity');
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
export function verifyTeamSnapshot(input) {
|
|
80
|
+
try {
|
|
81
|
+
if (Buffer.byteLength(JSON.stringify(input)) > TEAM_SNAPSHOT_MAX_BYTES)
|
|
82
|
+
throw new Error();
|
|
83
|
+
const snapshot = valid(snapshotSchema, input);
|
|
84
|
+
const { digest: stored, ...body } = snapshot;
|
|
85
|
+
if (teamDigest(JSON.stringify(body)) !== stored)
|
|
86
|
+
throw new Error();
|
|
87
|
+
const records = new Map();
|
|
88
|
+
const recordKey = (project, kind, key) => JSON.stringify([project, kind, key]);
|
|
89
|
+
let recordBytes = 0;
|
|
90
|
+
for (const row of snapshot.records) {
|
|
91
|
+
const key = recordKey(row.project, row.kind, row.id);
|
|
92
|
+
if (row.organization !== snapshot.organization || records.has(key))
|
|
93
|
+
throw new Error();
|
|
94
|
+
records.set(key, row.value);
|
|
95
|
+
recordBytes += Buffer.byteLength(JSON.stringify(row.value));
|
|
96
|
+
if (row.kind === 'credential') {
|
|
97
|
+
valid(digest, row.id);
|
|
98
|
+
valid(credentialSchema, row.value);
|
|
99
|
+
if (row.project !== '')
|
|
100
|
+
throw new Error();
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
valid(id, row.project);
|
|
104
|
+
if (row.kind === 'project') {
|
|
105
|
+
const project = valid(projectSchema, row.value);
|
|
106
|
+
if (project.id !== row.id || project.id !== row.project)
|
|
107
|
+
throw new Error();
|
|
108
|
+
}
|
|
109
|
+
else if (row.kind === 'member') {
|
|
110
|
+
const member = valid(memberSchema, row.value);
|
|
111
|
+
if (member.subject !== row.id)
|
|
112
|
+
throw new Error();
|
|
113
|
+
}
|
|
114
|
+
else if (row.kind === 'proposal') {
|
|
115
|
+
const proposal = checkedProposal(row.value);
|
|
116
|
+
if (proposal.organization !== row.organization || proposal.project !== row.project || proposal.id !== row.id)
|
|
117
|
+
throw new Error();
|
|
118
|
+
}
|
|
119
|
+
else if (row.kind === 'revision') {
|
|
120
|
+
const revision = checkedRevision(row.value);
|
|
121
|
+
if (revision.organization !== row.organization || revision.project !== row.project || revision.revision_digest !== row.id)
|
|
122
|
+
throw new Error();
|
|
123
|
+
}
|
|
124
|
+
else if (row.kind === 'review') {
|
|
125
|
+
const review = valid(reviewSchema, row.value);
|
|
126
|
+
if (review.proposal_id !== row.id)
|
|
127
|
+
throw new Error();
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
valid(memoryKey, row.id);
|
|
131
|
+
valid(digest, row.value);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (recordBytes > MAX_RECORD_BYTES)
|
|
136
|
+
throw new Error();
|
|
137
|
+
for (const row of snapshot.records.filter(row => row.kind !== 'credential')) {
|
|
138
|
+
if (!records.has(recordKey(row.project, 'project', row.project)))
|
|
139
|
+
throw new Error();
|
|
140
|
+
if (row.kind === 'head') {
|
|
141
|
+
const revision = checkedRevision(records.get(recordKey(row.project, 'revision', sqlText(row.value))));
|
|
142
|
+
if (revision.key !== row.id)
|
|
143
|
+
throw new Error();
|
|
144
|
+
}
|
|
145
|
+
if (row.kind === 'review') {
|
|
146
|
+
const review = valid(reviewSchema, row.value);
|
|
147
|
+
const proposal = checkedProposal(records.get(recordKey(row.project, 'proposal', row.id)));
|
|
148
|
+
if (review.proposal_digest !== proposal.digest || review.reviewer === proposal.subject)
|
|
149
|
+
throw new Error();
|
|
150
|
+
if (review.decision === 'approve') {
|
|
151
|
+
const revision = checkedRevision(records.get(recordKey(row.project, 'revision', review.revision_digest ?? '')));
|
|
152
|
+
if (revision.proposal_id !== row.id || revision.reviewer !== review.reviewer)
|
|
153
|
+
throw new Error();
|
|
154
|
+
}
|
|
155
|
+
else if (review.revision_digest !== null)
|
|
156
|
+
throw new Error();
|
|
157
|
+
}
|
|
158
|
+
if (row.kind === 'revision') {
|
|
159
|
+
const revision = checkedRevision(row.value);
|
|
160
|
+
const proposal = checkedProposal(records.get(recordKey(row.project, 'proposal', revision.proposal_id)));
|
|
161
|
+
const review = valid(reviewSchema, records.get(recordKey(row.project, 'review', revision.proposal_id)));
|
|
162
|
+
if (revision.proposal_digest !== proposal.digest || revision.key !== proposal.input.key || revision.value !== proposal.input.value
|
|
163
|
+
|| revision.source_scope !== proposal.input.source_scope || revision.source_digest !== proposal.input.source_digest
|
|
164
|
+
|| revision.base_revision !== proposal.input.base_revision || review.decision !== 'approve'
|
|
165
|
+
|| review.revision_digest !== revision.revision_digest || review.reviewer !== revision.reviewer || review.at !== revision.created_at)
|
|
166
|
+
throw new Error();
|
|
167
|
+
if (revision.base_revision !== null) {
|
|
168
|
+
const base = checkedRevision(records.get(recordKey(row.project, 'revision', revision.base_revision)));
|
|
169
|
+
if (base.key !== revision.key)
|
|
170
|
+
throw new Error();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
let previous = null;
|
|
175
|
+
let sequence = 0;
|
|
176
|
+
let auditBytes = 0;
|
|
177
|
+
for (const row of snapshot.audit) {
|
|
178
|
+
const entry = valid(auditEntrySchema, JSON.parse(row.value));
|
|
179
|
+
auditBytes += Buffer.byteLength(row.value);
|
|
180
|
+
if (row.organization !== snapshot.organization || row.sequence !== ++sequence || entry.sequence !== sequence || entry.previous_digest !== previous || entry.organization !== row.organization || entry.project !== row.project || row.digest !== teamDigest(row.value))
|
|
181
|
+
throw new Error();
|
|
182
|
+
previous = row.digest;
|
|
183
|
+
}
|
|
184
|
+
if (auditBytes > MAX_AUDIT_BYTES + 256 * 1024)
|
|
185
|
+
throw new Error();
|
|
186
|
+
return snapshot;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
throw new TeamError('integrity');
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function valid(schema, input) {
|
|
193
|
+
const result = schema.safeParse(input);
|
|
194
|
+
if (!result.success)
|
|
195
|
+
throw new TeamError('invalid');
|
|
196
|
+
return result.data;
|
|
197
|
+
}
|
|
198
|
+
function sqlText(value) {
|
|
199
|
+
if (typeof value !== 'string')
|
|
200
|
+
throw new TeamError('integrity');
|
|
201
|
+
return value;
|
|
202
|
+
}
|
|
203
|
+
function privateDirectory(root, create) {
|
|
204
|
+
if (!isAbsolute(root) || root === parse(root).root)
|
|
205
|
+
throw new TeamError('invalid');
|
|
206
|
+
// Resolve nothing through links. Existing ancestors must not be writable by
|
|
207
|
+
// other non-owner users; the storage directory itself must be private.
|
|
208
|
+
let current = root;
|
|
209
|
+
while (current !== dirname(current)) {
|
|
210
|
+
if (existsSync(current)) {
|
|
211
|
+
const info = lstatSync(current);
|
|
212
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
213
|
+
throw new TeamError('integrity');
|
|
214
|
+
if (process.platform !== 'win32' && (info.mode & 0o022) !== 0 && (info.mode & 0o1000) === 0)
|
|
215
|
+
throw new TeamError('integrity');
|
|
216
|
+
}
|
|
217
|
+
current = dirname(current);
|
|
218
|
+
}
|
|
219
|
+
if (!existsSync(root)) {
|
|
220
|
+
if (!create)
|
|
221
|
+
throw new TeamError('invalid');
|
|
222
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
223
|
+
hardenPrivatePath(root);
|
|
224
|
+
}
|
|
225
|
+
if (!privatePathIsSafe(root))
|
|
226
|
+
throw new TeamError('integrity');
|
|
227
|
+
}
|
|
228
|
+
/** Separate single-organization storage. Local administration trusts the OS
|
|
229
|
+
* owner, not a client-supplied actor label. All remote authority is credential
|
|
230
|
+
* derived and checked inside the same transaction as state and audit writes. */
|
|
231
|
+
export class TeamStore {
|
|
232
|
+
agent;
|
|
233
|
+
organization;
|
|
234
|
+
root;
|
|
235
|
+
constructor(agent, organization, root) {
|
|
236
|
+
this.agent = agent;
|
|
237
|
+
this.organization = organization;
|
|
238
|
+
this.root = root;
|
|
239
|
+
}
|
|
240
|
+
static async open(root, options = {}) {
|
|
241
|
+
if (process.env.CAIRN_TEAM !== '1')
|
|
242
|
+
throw new TeamError('disabled');
|
|
243
|
+
if (options.organization !== undefined)
|
|
244
|
+
valid(id, options.organization);
|
|
245
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
246
|
+
root = resolve(root);
|
|
247
|
+
privateDirectory(root, !!options.create);
|
|
248
|
+
const path = join(root, 'team.db');
|
|
249
|
+
if (options.create && options.organization) {
|
|
250
|
+
// Atomic creation decides ownership; never authorize it using a
|
|
251
|
+
// preceding pathname existence check or truncate another creator.
|
|
252
|
+
try {
|
|
253
|
+
const fd = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
254
|
+
closeSync(fd);
|
|
255
|
+
hardenPrivatePath(path);
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
if (error.code !== 'EEXIST')
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
for (const file of [path, `${path}-wal`, `${path}-shm`]) {
|
|
263
|
+
let info;
|
|
264
|
+
try {
|
|
265
|
+
info = lstatSync(file);
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
if (error.code !== 'ENOENT')
|
|
269
|
+
throw error;
|
|
270
|
+
if (file === path)
|
|
271
|
+
throw new TeamError('invalid');
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (!info.isFile() || info.isSymbolicLink() || !privatePathIsSafe(file))
|
|
275
|
+
throw new TeamError('integrity');
|
|
276
|
+
}
|
|
277
|
+
// Built-in SQLite is available without a flag from Node 22.13 onward.
|
|
278
|
+
// Resolve it only after opt-in, so disabled mode performs no team reads.
|
|
279
|
+
const connection = new DatabaseSync(path);
|
|
280
|
+
const agent = { getDatabase: () => connection, close: async () => connection.close() };
|
|
281
|
+
try {
|
|
282
|
+
const initialized = (queues.get(root) ?? Promise.resolve()).then(async () => {
|
|
283
|
+
const db = agent.getDatabase();
|
|
284
|
+
db.exec('PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;');
|
|
285
|
+
await db.exec(`CREATE TABLE IF NOT EXISTS team_records (organization TEXT NOT NULL, project TEXT NOT NULL, kind TEXT NOT NULL, id TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY(organization, project, kind, id));
|
|
286
|
+
CREATE TABLE IF NOT EXISTS team_meta (id TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
287
|
+
CREATE TABLE IF NOT EXISTS team_audit (sequence INTEGER PRIMARY KEY, organization TEXT NOT NULL, project TEXT NOT NULL, value TEXT NOT NULL, digest TEXT NOT NULL);`);
|
|
288
|
+
const org = await immediate(db, async () => {
|
|
289
|
+
const row = await db.prepare('SELECT value FROM team_meta WHERE id = ?').get('organization');
|
|
290
|
+
if (row) {
|
|
291
|
+
const org = valid(id, row.value);
|
|
292
|
+
if (options.organization && org !== options.organization)
|
|
293
|
+
throw new TeamError('denied');
|
|
294
|
+
return org;
|
|
295
|
+
}
|
|
296
|
+
if (!options.create || !options.organization)
|
|
297
|
+
throw new TeamError('integrity');
|
|
298
|
+
await db.prepare('INSERT INTO team_meta(id,value) VALUES(?,?)').run('organization', options.organization);
|
|
299
|
+
await db.prepare('INSERT INTO team_meta(id,value) VALUES(?,?)').run('schema_version', '1');
|
|
300
|
+
return options.organization;
|
|
301
|
+
});
|
|
302
|
+
const version = await db.prepare('SELECT value FROM team_meta WHERE id = ?').get('schema_version');
|
|
303
|
+
if (version?.value !== '1')
|
|
304
|
+
throw new TeamError('integrity');
|
|
305
|
+
const store = new TeamStore(agent, org, root);
|
|
306
|
+
store.harden();
|
|
307
|
+
return store;
|
|
308
|
+
});
|
|
309
|
+
queues.set(root, initialized.catch(() => undefined));
|
|
310
|
+
return await initialized;
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
await agent.close();
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
harden() {
|
|
318
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
319
|
+
const file = join(this.root, `team.db${suffix}`);
|
|
320
|
+
if (existsSync(file))
|
|
321
|
+
hardenPrivatePath(file);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
async transaction(operation) {
|
|
325
|
+
const result = (queues.get(this.root) ?? Promise.resolve()).then(async () => {
|
|
326
|
+
privateDirectory(this.root, false);
|
|
327
|
+
try {
|
|
328
|
+
return await immediate(this.agent.getDatabase(), operation);
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
this.harden();
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
queues.set(this.root, result.catch(() => undefined));
|
|
335
|
+
return result;
|
|
336
|
+
}
|
|
337
|
+
async rows(kind, project) {
|
|
338
|
+
const rows = await this.agent.getDatabase().prepare('SELECT id,value FROM team_records WHERE organization=? AND project=? AND kind=? ORDER BY id').all(this.organization, project, kind);
|
|
339
|
+
return rows.map(row => {
|
|
340
|
+
try {
|
|
341
|
+
return { id: sqlText(row.id), value: JSON.parse(sqlText(row.value)) };
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
throw new TeamError('integrity');
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
async get(kind, project, recordId) {
|
|
349
|
+
const row = await this.agent.getDatabase().prepare('SELECT value FROM team_records WHERE organization=? AND project=? AND kind=? AND id=?').get(this.organization, project, kind, recordId);
|
|
350
|
+
if (!row)
|
|
351
|
+
return undefined;
|
|
352
|
+
try {
|
|
353
|
+
return JSON.parse(sqlText(row.value));
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
throw new TeamError('integrity');
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
async set(kind, project, recordId, value, immutable = false) {
|
|
360
|
+
const sql = immutable ? 'INSERT INTO team_records(organization,project,kind,id,value) VALUES(?,?,?,?,?)'
|
|
361
|
+
: 'INSERT INTO team_records(organization,project,kind,id,value) VALUES(?,?,?,?,?) ON CONFLICT(organization,project,kind,id) DO UPDATE SET value=excluded.value';
|
|
362
|
+
await this.agent.getDatabase().prepare(sql).run(this.organization, project, kind, recordId, JSON.stringify(value));
|
|
363
|
+
const size = this.agent.getDatabase().prepare('SELECT count(*) AS entries,coalesce(sum(length(CAST(value AS BLOB))),0) AS bytes FROM team_records').get();
|
|
364
|
+
if (Number(size?.entries) > 32768 || Number(size?.bytes) > MAX_RECORD_BYTES)
|
|
365
|
+
throw new TeamError('conflict');
|
|
366
|
+
}
|
|
367
|
+
async appendAudit(project, subject, credentialClass, operation, result, objectDigest = null) {
|
|
368
|
+
const db = this.agent.getDatabase();
|
|
369
|
+
const last = await db.prepare('SELECT sequence,digest FROM team_audit ORDER BY sequence DESC LIMIT 1').get();
|
|
370
|
+
const sequence = valid(z.number().int().nonnegative(), last?.sequence ?? 0) + 1;
|
|
371
|
+
const entry = { schema_version: 1, sequence, organization: this.organization, project, subject, credential_class: credentialClass, operation, result, object_digest: objectDigest, previous_digest: last?.digest ?? null, request_id: randomUUID(), at: new Date().toISOString() };
|
|
372
|
+
const value = JSON.stringify(entry);
|
|
373
|
+
const size = db.prepare('SELECT count(*) AS entries,coalesce(sum(length(CAST(value AS BLOB))),0) AS bytes FROM team_audit').get();
|
|
374
|
+
// Reserve bounded space for local revocation/recovery after a client's
|
|
375
|
+
// audit allowance is exhausted. Full stores fail closed, never discard.
|
|
376
|
+
const allowance = MAX_AUDIT_BYTES + (credentialClass === 'local-os' ? 256 * 1024 : 0);
|
|
377
|
+
const entryAllowance = credentialClass === 'local-os' ? 100000 : 99000;
|
|
378
|
+
if (Number(size?.entries) >= entryAllowance || Number(size?.bytes) + Buffer.byteLength(value) > allowance)
|
|
379
|
+
throw new TeamError('conflict');
|
|
380
|
+
await db.prepare('INSERT INTO team_audit(sequence,organization,project,value,digest) VALUES(?,?,?,?,?)').run(sequence, this.organization, project, value, teamDigest(value));
|
|
381
|
+
}
|
|
382
|
+
async admin(input) {
|
|
383
|
+
const request = valid(adminSchema, input);
|
|
384
|
+
await this.transaction(async () => {
|
|
385
|
+
if (request.operation === 'project-create') {
|
|
386
|
+
if (await this.get('project', request.project, request.project))
|
|
387
|
+
throw new TeamError('conflict');
|
|
388
|
+
await this.set('project', request.project, request.project, { id: request.project, policy_revision: 0 }, true);
|
|
389
|
+
}
|
|
390
|
+
else if (request.operation === 'member-set') {
|
|
391
|
+
const project = valid(projectSchema, await this.get('project', request.project, request.project));
|
|
392
|
+
await this.set('member', request.project, request.subject, { subject: request.subject, roles: [...new Set(request.roles)].sort() });
|
|
393
|
+
await this.set('project', request.project, request.project, { ...project, policy_revision: project.policy_revision + 1 });
|
|
394
|
+
}
|
|
395
|
+
else if (request.operation === 'memory-delete') {
|
|
396
|
+
if (await this.get('head', request.project, request.key) !== request.confirm)
|
|
397
|
+
throw new TeamError('conflict');
|
|
398
|
+
const db = this.agent.getDatabase();
|
|
399
|
+
for (const row of await this.rows('proposal', request.project)) {
|
|
400
|
+
if (checkedProposal(row.value).input.key !== request.key)
|
|
401
|
+
continue;
|
|
402
|
+
db.prepare('DELETE FROM team_records WHERE organization=? AND project=? AND kind IN (?,?) AND id=?').run(this.organization, request.project, 'proposal', 'review', row.id);
|
|
403
|
+
}
|
|
404
|
+
for (const row of await this.rows('revision', request.project)) {
|
|
405
|
+
if (checkedRevision(row.value).key === request.key)
|
|
406
|
+
db.prepare('DELETE FROM team_records WHERE organization=? AND project=? AND kind=? AND id=?').run(this.organization, request.project, 'revision', row.id);
|
|
407
|
+
}
|
|
408
|
+
db.prepare('DELETE FROM team_records WHERE organization=? AND project=? AND kind=? AND id=?').run(this.organization, request.project, 'head', request.key);
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
const rows = await this.agent.getDatabase().prepare('SELECT id,value FROM team_records WHERE organization=? AND project=? AND kind=?').all(this.organization, '', 'credential');
|
|
412
|
+
const found = rows.find(row => valid(credentialSchema, JSON.parse(String(row.value))).id === request.credential);
|
|
413
|
+
if (!found)
|
|
414
|
+
throw new TeamError('invalid');
|
|
415
|
+
const credential = valid(credentialSchema, JSON.parse(String(found.value)));
|
|
416
|
+
await this.set('credential', '', sqlText(found.id), { ...credential, revoked: true });
|
|
417
|
+
}
|
|
418
|
+
await this.appendAudit('project' in request ? request.project : '', 'local-operator', 'local-os', request.operation, 'accepted', request.operation === 'memory-delete' ? request.confirm : null);
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
async issue(input) {
|
|
422
|
+
const request = valid(credentialSchema.omit({ id: true, revoked: true, audience: true }), input);
|
|
423
|
+
if (Date.parse(request.expires_at) <= Date.now() || Date.parse(request.expires_at) > Date.now() + 366 * 86400000)
|
|
424
|
+
throw new TeamError('invalid');
|
|
425
|
+
const token = randomBytes(32).toString('base64url');
|
|
426
|
+
const credential = { ...request, id: randomUUID(), revoked: false, audience: 'cairnkeep-team-v1' };
|
|
427
|
+
await this.transaction(async () => {
|
|
428
|
+
await this.set('credential', '', teamDigest(token), credential, true);
|
|
429
|
+
await this.appendAudit('', request.subject, 'local-os', 'credential-issue', 'accepted');
|
|
430
|
+
});
|
|
431
|
+
return { id: credential.id, token };
|
|
432
|
+
}
|
|
433
|
+
async authenticate(token) {
|
|
434
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(token))
|
|
435
|
+
throw new TeamError('unauthorized');
|
|
436
|
+
const raw = await this.get('credential', '', teamDigest(token));
|
|
437
|
+
if (!raw)
|
|
438
|
+
throw new TeamError('unauthorized');
|
|
439
|
+
const credential = valid(credentialSchema, raw);
|
|
440
|
+
if (credential.revoked || Date.parse(credential.expires_at) <= Date.now())
|
|
441
|
+
throw new TeamError('unauthorized');
|
|
442
|
+
return credential;
|
|
443
|
+
}
|
|
444
|
+
async checkCredential(token) {
|
|
445
|
+
return this.transaction(async () => ({ subject: (await this.authenticate(token)).subject }));
|
|
446
|
+
}
|
|
447
|
+
async execute(token, input) {
|
|
448
|
+
const request = valid(requestSchema, input);
|
|
449
|
+
try {
|
|
450
|
+
return await this.transaction(async () => {
|
|
451
|
+
const credential = await this.authenticate(token);
|
|
452
|
+
const member = valid(memberSchema, await this.get('member', request.project, credential.subject) ?? { subject: credential.subject, roles: [] });
|
|
453
|
+
const required = request.operation === 'audit' ? 'auditor' : ['propose', 'proposals', 'proposal-show'].includes(request.operation) ? 'contributor' : request.operation === 'review' ? 'reviewer' : 'reader';
|
|
454
|
+
const proposalAccess = ['proposals', 'proposal-show'].includes(request.operation) && (member.roles.includes('contributor') || member.roles.includes('reviewer'));
|
|
455
|
+
if (request.organization !== this.organization || (!proposalAccess && !member.roles.includes(required)))
|
|
456
|
+
throw new TeamError('denied');
|
|
457
|
+
if (request.operation === 'review' && credential.credential_class !== 'human')
|
|
458
|
+
throw new TeamError('denied');
|
|
459
|
+
const project = valid(projectSchema, await this.get('project', request.project, request.project));
|
|
460
|
+
let result;
|
|
461
|
+
let objectDigest = null;
|
|
462
|
+
if (request.operation === 'audit') {
|
|
463
|
+
const rows = await this.agent.getDatabase().prepare('SELECT value,digest FROM team_audit WHERE organization=? AND project=? ORDER BY sequence DESC LIMIT 100').all(this.organization, request.project);
|
|
464
|
+
result = rows.map(row => ({ ...JSON.parse(sqlText(row.value)), digest: row.digest }));
|
|
465
|
+
}
|
|
466
|
+
else if (request.operation === 'propose') {
|
|
467
|
+
const { organization: _org, project: _project, operation: _op, ...rawInput } = request;
|
|
468
|
+
const selected = valid(proposalInput, rawInput);
|
|
469
|
+
if (selected.confirm !== teamDigest(selected.value) || Date.parse(selected.expires_at) <= Date.now() || Date.parse(selected.expires_at) > Date.now() + 30 * 86400000)
|
|
470
|
+
throw new TeamError('invalid');
|
|
471
|
+
const prior = await this.get('proposal', request.project, selected.request_id);
|
|
472
|
+
if (prior) {
|
|
473
|
+
const proposal = checkedProposal(prior);
|
|
474
|
+
if (proposal.subject !== credential.subject || JSON.stringify(proposal.input) !== JSON.stringify(selected))
|
|
475
|
+
throw new TeamError('conflict');
|
|
476
|
+
result = { id: proposal.id, digest: proposal.digest };
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
const body = { schema_version: 1, id: selected.request_id, organization: this.organization, project: request.project, subject: credential.subject, policy_digest: teamDigest(JSON.stringify(project)), created_at: new Date().toISOString(), input: selected };
|
|
480
|
+
const proposal = { ...body, digest: teamDigest(JSON.stringify(body)) };
|
|
481
|
+
await this.set('proposal', request.project, proposal.id, proposal, true);
|
|
482
|
+
objectDigest = proposal.digest;
|
|
483
|
+
result = { id: proposal.id, digest: proposal.digest };
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
else if (request.operation === 'review') {
|
|
487
|
+
const proposal = checkedProposal(await this.get('proposal', request.project, request.proposal_id));
|
|
488
|
+
if (proposal.subject === credential.subject)
|
|
489
|
+
throw new TeamError('denied');
|
|
490
|
+
if (proposal.digest !== request.confirm)
|
|
491
|
+
throw new TeamError('conflict');
|
|
492
|
+
const prior = await this.get('review', request.project, request.proposal_id);
|
|
493
|
+
if (prior) {
|
|
494
|
+
const review = valid(reviewSchema, prior);
|
|
495
|
+
if (review.reviewer !== credential.subject || review.decision !== request.decision)
|
|
496
|
+
throw new TeamError('conflict');
|
|
497
|
+
result = review;
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
if (Date.parse(proposal.input.expires_at) <= Date.now() || proposal.policy_digest !== teamDigest(JSON.stringify(project)))
|
|
501
|
+
throw new TeamError('conflict');
|
|
502
|
+
const head = await this.get('head', request.project, proposal.input.key);
|
|
503
|
+
if (request.decision === 'approve' && (head ?? null) !== proposal.input.base_revision)
|
|
504
|
+
throw new TeamError('conflict');
|
|
505
|
+
const at = new Date().toISOString();
|
|
506
|
+
let revisionDigest = null;
|
|
507
|
+
if (request.decision === 'approve') {
|
|
508
|
+
const body = { schema_version: 1, organization: this.organization, project: request.project, key: proposal.input.key, value: proposal.input.value, proposal_id: proposal.id, proposal_digest: proposal.digest, reviewer: credential.subject, source_scope: proposal.input.source_scope, source_digest: proposal.input.source_digest, base_revision: proposal.input.base_revision, created_at: at };
|
|
509
|
+
revisionDigest = teamDigest(JSON.stringify(body));
|
|
510
|
+
await this.set('revision', request.project, revisionDigest, { ...body, revision_digest: revisionDigest }, true);
|
|
511
|
+
await this.set('head', request.project, proposal.input.key, revisionDigest);
|
|
512
|
+
}
|
|
513
|
+
const review = { proposal_id: proposal.id, proposal_digest: proposal.digest, reviewer: credential.subject, decision: request.decision, revision_digest: revisionDigest, at };
|
|
514
|
+
await this.set('review', request.project, proposal.id, review, true);
|
|
515
|
+
objectDigest = revisionDigest ?? proposal.digest;
|
|
516
|
+
result = review;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
else if (request.operation === 'proposal-show' || request.operation === 'proposals') {
|
|
520
|
+
const visible = (await this.rows('proposal', request.project)).map(row => checkedProposal(row.value)).filter(proposal => member.roles.includes('reviewer') || proposal.subject === credential.subject);
|
|
521
|
+
if (request.operation === 'proposal-show') {
|
|
522
|
+
const proposal = visible.find(proposal => proposal.id === request.proposal_id);
|
|
523
|
+
if (!proposal)
|
|
524
|
+
throw new TeamError('denied');
|
|
525
|
+
result = proposal;
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
result = await Promise.all(visible.slice(0, 100).map(async (proposal) => ({ id: proposal.id, digest: proposal.digest, key: proposal.input.key, subject: proposal.subject, expires_at: proposal.input.expires_at, status: (await this.get('review', request.project, proposal.id)) ? 'reviewed' : 'pending' })));
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
else if (request.operation === 'read') {
|
|
532
|
+
const revisionDigest = request.revision ?? await this.get('head', request.project, request.key);
|
|
533
|
+
if (!revisionDigest)
|
|
534
|
+
result = null;
|
|
535
|
+
else {
|
|
536
|
+
const revision = checkedRevision(await this.get('revision', request.project, valid(digest, revisionDigest)));
|
|
537
|
+
if (revision.key !== request.key)
|
|
538
|
+
throw new TeamError('denied');
|
|
539
|
+
result = this.presentRevision(revision);
|
|
540
|
+
objectDigest = revision.revision_digest;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
else if (request.operation === 'history') {
|
|
544
|
+
result = (await this.rows('revision', request.project)).map(row => checkedRevision(row.value)).filter(revision => revision.key === request.key).sort((a, b) => b.created_at.localeCompare(a.created_at) || a.revision_digest.localeCompare(b.revision_digest)).slice(0, 100).map(({ value: _value, ...metadata }) => metadata);
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
const heads = await this.rows('head', request.project);
|
|
548
|
+
const revisions = await Promise.all(heads.map(async (head) => checkedRevision(await this.get('revision', request.project, valid(digest, head.value)))));
|
|
549
|
+
if (request.operation === 'search') {
|
|
550
|
+
const needle = request.query.toLowerCase();
|
|
551
|
+
result = revisions.filter(revision => `${revision.key}\n${revision.value}`.toLowerCase().includes(needle)).slice(0, 10).map(revision => this.presentRevision(revision));
|
|
552
|
+
}
|
|
553
|
+
else
|
|
554
|
+
result = revisions.slice(0, 100).map(({ value: _value, ...metadata }) => metadata);
|
|
555
|
+
}
|
|
556
|
+
await this.appendAudit(request.project, credential.subject, credential.credential_class, request.operation, 'accepted', objectDigest);
|
|
557
|
+
return result;
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
catch (error) {
|
|
561
|
+
// A failed transaction rolls back ALL publication writes. Record only
|
|
562
|
+
// bounded metadata afterward, never input text or bearer credentials.
|
|
563
|
+
if (error instanceof TeamError && ['denied', 'conflict', 'invalid'].includes(error.code)) {
|
|
564
|
+
await this.transaction(async () => {
|
|
565
|
+
const credential = await this.authenticate(token);
|
|
566
|
+
await this.appendAudit(request.organization === this.organization ? request.project : '', credential.subject, credential.credential_class, request.operation, error.code === 'denied' ? 'denied' : error.code);
|
|
567
|
+
}).catch(() => undefined);
|
|
568
|
+
}
|
|
569
|
+
throw error;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
presentRevision(revision) {
|
|
573
|
+
const { value, ...provenance } = revision;
|
|
574
|
+
return { key: revision.key, value, provenance };
|
|
575
|
+
}
|
|
576
|
+
collectSnapshot() {
|
|
577
|
+
const db = this.agent.getDatabase();
|
|
578
|
+
const records = db.prepare('SELECT organization,project,kind,id,value FROM team_records ORDER BY organization,project,kind,id').all().map(row => ({ organization: sqlText(row.organization), project: sqlText(row.project), kind: sqlText(row.kind), id: sqlText(row.id), value: JSON.parse(sqlText(row.value)) }));
|
|
579
|
+
const audit = db.prepare('SELECT sequence,organization,project,value,digest FROM team_audit ORDER BY sequence').all();
|
|
580
|
+
const body = { schema_version: 1, organization: this.organization, records, audit };
|
|
581
|
+
return verifyTeamSnapshot({ ...body, digest: teamDigest(JSON.stringify(body)) });
|
|
582
|
+
}
|
|
583
|
+
async snapshot() {
|
|
584
|
+
return this.transaction(async () => {
|
|
585
|
+
await this.appendAudit('', 'local-operator', 'local-os', 'snapshot', 'accepted');
|
|
586
|
+
return this.collectSnapshot();
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
async doctor() {
|
|
590
|
+
return this.transaction(async () => {
|
|
591
|
+
if (this.agent.getDatabase().prepare('PRAGMA quick_check').get()?.quick_check !== 'ok')
|
|
592
|
+
throw new TeamError('integrity');
|
|
593
|
+
const snapshot = this.collectSnapshot();
|
|
594
|
+
// Report only a count. Never select a restore, expose paths or delete remnants.
|
|
595
|
+
const remnants = readdirSync(dirname(this.root)).filter(name => name.startsWith('.team-restore-') || name === `.${basename(this.root)}.restore.lock`).length;
|
|
596
|
+
return { ok: true, records: snapshot.records.length, audit_events: snapshot.audit.length, digest: snapshot.digest, temporary_remnants: remnants };
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
static async restore(root, input, confirm) {
|
|
600
|
+
if (process.env.CAIRN_TEAM !== '1')
|
|
601
|
+
throw new TeamError('disabled');
|
|
602
|
+
const snapshot = verifyTeamSnapshot(input);
|
|
603
|
+
if (snapshot.digest !== confirm)
|
|
604
|
+
throw new TeamError('conflict');
|
|
605
|
+
root = resolve(root);
|
|
606
|
+
privateDirectory(dirname(root), false);
|
|
607
|
+
const absent = () => { try {
|
|
608
|
+
lstatSync(root);
|
|
609
|
+
throw new TeamError('conflict');
|
|
610
|
+
}
|
|
611
|
+
catch (error) {
|
|
612
|
+
if (error.code !== 'ENOENT')
|
|
613
|
+
throw error;
|
|
614
|
+
} };
|
|
615
|
+
absent();
|
|
616
|
+
const lock = join(dirname(root), `.${basename(root)}.restore.lock`);
|
|
617
|
+
const lockFd = openSync(lock, 'wx', 0o600);
|
|
618
|
+
let temporary;
|
|
619
|
+
let store;
|
|
620
|
+
try {
|
|
621
|
+
hardenPrivatePath(lock);
|
|
622
|
+
temporary = mkdtempSync(join(dirname(root), '.team-restore-'));
|
|
623
|
+
hardenPrivatePath(temporary);
|
|
624
|
+
absent();
|
|
625
|
+
store = await TeamStore.open(temporary, { create: true, organization: snapshot.organization });
|
|
626
|
+
await store.transaction(async () => {
|
|
627
|
+
const db = store.agent.getDatabase();
|
|
628
|
+
for (const row of snapshot.records) {
|
|
629
|
+
const value = row.kind === 'credential' ? { ...valid(credentialSchema, row.value), revoked: true } : row.value;
|
|
630
|
+
db.prepare('INSERT INTO team_records(organization,project,kind,id,value) VALUES(?,?,?,?,?)').run(row.organization, row.project, row.kind, row.id, JSON.stringify(value));
|
|
631
|
+
}
|
|
632
|
+
for (const row of snapshot.audit)
|
|
633
|
+
db.prepare('INSERT INTO team_audit(sequence,organization,project,value,digest) VALUES(?,?,?,?,?)').run(row.sequence, row.organization, row.project, row.value, row.digest);
|
|
634
|
+
await store.appendAudit('', 'local-operator', 'local-os', 'restore', 'accepted', snapshot.digest);
|
|
635
|
+
});
|
|
636
|
+
await store.doctor();
|
|
637
|
+
await store.close();
|
|
638
|
+
store = undefined;
|
|
639
|
+
absent();
|
|
640
|
+
renameSync(temporary, root);
|
|
641
|
+
return await TeamStore.open(root);
|
|
642
|
+
}
|
|
643
|
+
finally {
|
|
644
|
+
try {
|
|
645
|
+
await store?.close();
|
|
646
|
+
}
|
|
647
|
+
finally {
|
|
648
|
+
try {
|
|
649
|
+
if (temporary && existsSync(temporary))
|
|
650
|
+
rmSync(temporary, { recursive: true, force: true });
|
|
651
|
+
}
|
|
652
|
+
finally {
|
|
653
|
+
closeSync(lockFd);
|
|
654
|
+
unlinkSync(lock);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
async close() { await queues.get(this.root); await this.agent.close(); }
|
|
660
|
+
}
|
|
661
|
+
export const openTeamStore = TeamStore.open;
|
|
662
|
+
export const restoreTeamSnapshot = TeamStore.restore;
|
|
663
|
+
export const prepareTeamPrivateDirectory = privateDirectory;
|