@synoikia/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/app.d.ts +79 -0
- package/dist/app.js +155 -0
- package/dist/app.js.map +1 -0
- package/dist/approvals/links.d.ts +23 -0
- package/dist/approvals/links.js +49 -0
- package/dist/approvals/links.js.map +1 -0
- package/dist/approvals/service.d.ts +110 -0
- package/dist/approvals/service.js +182 -0
- package/dist/approvals/service.js.map +1 -0
- package/dist/audit-query.d.ts +52 -0
- package/dist/audit-query.js +116 -0
- package/dist/audit-query.js.map +1 -0
- package/dist/audit.d.ts +5 -0
- package/dist/audit.js +8 -0
- package/dist/audit.js.map +1 -0
- package/dist/auth/mcp-auth.d.ts +50 -0
- package/dist/auth/mcp-auth.js +160 -0
- package/dist/auth/mcp-auth.js.map +1 -0
- package/dist/auth/mcp-tokens.d.ts +57 -0
- package/dist/auth/mcp-tokens.js +143 -0
- package/dist/auth/mcp-tokens.js.map +1 -0
- package/dist/auth/oauth.d.ts +188 -0
- package/dist/auth/oauth.js +470 -0
- package/dist/auth/oauth.js.map +1 -0
- package/dist/auth/oidc.d.ts +110 -0
- package/dist/auth/oidc.js +221 -0
- package/dist/auth/oidc.js.map +1 -0
- package/dist/auth/sessions.d.ts +34 -0
- package/dist/auth/sessions.js +83 -0
- package/dist/auth/sessions.js.map +1 -0
- package/dist/auth/throttle.d.ts +31 -0
- package/dist/auth/throttle.js +52 -0
- package/dist/auth/throttle.js.map +1 -0
- package/dist/auth/tokens.d.ts +7 -0
- package/dist/auth/tokens.js +40 -0
- package/dist/auth/tokens.js.map +1 -0
- package/dist/auth/totp.d.ts +14 -0
- package/dist/auth/totp.js +81 -0
- package/dist/auth/totp.js.map +1 -0
- package/dist/auth/users.d.ts +70 -0
- package/dist/auth/users.js +296 -0
- package/dist/auth/users.js.map +1 -0
- package/dist/catalog/groups.d.ts +102 -0
- package/dist/catalog/groups.js +363 -0
- package/dist/catalog/groups.js.map +1 -0
- package/dist/catalog/registry.d.ts +25 -0
- package/dist/catalog/registry.js +80 -0
- package/dist/catalog/registry.js.map +1 -0
- package/dist/catalog/rules.d.ts +128 -0
- package/dist/catalog/rules.js +178 -0
- package/dist/catalog/rules.js.map +1 -0
- package/dist/catalog/sync.d.ts +29 -0
- package/dist/catalog/sync.js +222 -0
- package/dist/catalog/sync.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +83 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/env.d.ts +40 -0
- package/dist/config/env.js +71 -0
- package/dist/config/env.js.map +1 -0
- package/dist/crypto/index.d.ts +28 -0
- package/dist/crypto/index.js +88 -0
- package/dist/crypto/index.js.map +1 -0
- package/dist/crypto/rotate.d.ts +15 -0
- package/dist/crypto/rotate.js +61 -0
- package/dist/crypto/rotate.js.map +1 -0
- package/dist/db/index.d.ts +17 -0
- package/dist/db/index.js +29 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/schema.d.ts +4389 -0
- package/dist/db/schema.js +372 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/endpoints/slug.d.ts +3 -0
- package/dist/endpoints/slug.js +7 -0
- package/dist/endpoints/slug.js.map +1 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.js +29 -0
- package/dist/errors.js.map +1 -0
- package/dist/events.d.ts +43 -0
- package/dist/events.js +16 -0
- package/dist/events.js.map +1 -0
- package/dist/gate/access.d.ts +47 -0
- package/dist/gate/access.js +48 -0
- package/dist/gate/access.js.map +1 -0
- package/dist/gate/attestation.d.ts +23 -0
- package/dist/gate/attestation.js +33 -0
- package/dist/gate/attestation.js.map +1 -0
- package/dist/gate/canonical.d.ts +3 -0
- package/dist/gate/canonical.js +11 -0
- package/dist/gate/canonical.js.map +1 -0
- package/dist/gate/match.d.ts +67 -0
- package/dist/gate/match.js +133 -0
- package/dist/gate/match.js.map +1 -0
- package/dist/gate/pipeline.d.ts +61 -0
- package/dist/gate/pipeline.js +284 -0
- package/dist/gate/pipeline.js.map +1 -0
- package/dist/gate/preapproval.d.ts +24 -0
- package/dist/gate/preapproval.js +48 -0
- package/dist/gate/preapproval.js.map +1 -0
- package/dist/gate/rate-limit.d.ts +35 -0
- package/dist/gate/rate-limit.js +82 -0
- package/dist/gate/rate-limit.js.map +1 -0
- package/dist/gate/redact.d.ts +23 -0
- package/dist/gate/redact.js +87 -0
- package/dist/gate/redact.js.map +1 -0
- package/dist/http/admin/auth.d.ts +33 -0
- package/dist/http/admin/auth.js +338 -0
- package/dist/http/admin/auth.js.map +1 -0
- package/dist/http/admin/instances.d.ts +5 -0
- package/dist/http/admin/instances.js +193 -0
- package/dist/http/admin/instances.js.map +1 -0
- package/dist/http/admin/system.d.ts +5 -0
- package/dist/http/admin/system.js +225 -0
- package/dist/http/admin/system.js.map +1 -0
- package/dist/http/admin-app.d.ts +9 -0
- package/dist/http/admin-app.js +51 -0
- package/dist/http/admin-app.js.map +1 -0
- package/dist/http/common.d.ts +32 -0
- package/dist/http/common.js +80 -0
- package/dist/http/common.js.map +1 -0
- package/dist/http/mcp/approval-routes.d.ts +11 -0
- package/dist/http/mcp/approval-routes.js +117 -0
- package/dist/http/mcp/approval-routes.js.map +1 -0
- package/dist/http/mcp/endpoint.d.ts +32 -0
- package/dist/http/mcp/endpoint.js +293 -0
- package/dist/http/mcp/endpoint.js.map +1 -0
- package/dist/http/mcp/oauth-routes.d.ts +29 -0
- package/dist/http/mcp/oauth-routes.js +465 -0
- package/dist/http/mcp/oauth-routes.js.map +1 -0
- package/dist/http/mcp/pages.d.ts +63 -0
- package/dist/http/mcp/pages.js +200 -0
- package/dist/http/mcp/pages.js.map +1 -0
- package/dist/http/mcp-app.d.ts +7 -0
- package/dist/http/mcp-app.js +30 -0
- package/dist/http/mcp-app.js.map +1 -0
- package/dist/instances/connection.d.ts +37 -0
- package/dist/instances/connection.js +109 -0
- package/dist/instances/connection.js.map +1 -0
- package/dist/instances/manager.d.ts +313 -0
- package/dist/instances/manager.js +600 -0
- package/dist/instances/manager.js.map +1 -0
- package/dist/instances/settings.d.ts +42 -0
- package/dist/instances/settings.js +57 -0
- package/dist/instances/settings.js.map +1 -0
- package/dist/lenient.d.ts +16 -0
- package/dist/lenient.js +51 -0
- package/dist/lenient.js.map +1 -0
- package/dist/lock.d.ts +28 -0
- package/dist/lock.js +77 -0
- package/dist/lock.js.map +1 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +38 -0
- package/dist/main.js.map +1 -0
- package/dist/maintenance.d.ts +10 -0
- package/dist/maintenance.js +48 -0
- package/dist/maintenance.js.map +1 -0
- package/dist/notify/service.d.ts +149 -0
- package/dist/notify/service.js +358 -0
- package/dist/notify/service.js.map +1 -0
- package/dist/plugins/discovery.d.ts +39 -0
- package/dist/plugins/discovery.js +157 -0
- package/dist/plugins/discovery.js.map +1 -0
- package/dist/plugins/minisign.d.ts +25 -0
- package/dist/plugins/minisign.js +49 -0
- package/dist/plugins/minisign.js.map +1 -0
- package/dist/plugins/process.d.ts +55 -0
- package/dist/plugins/process.js +162 -0
- package/dist/plugins/process.js.map +1 -0
- package/dist/plugins/repos.d.ts +198 -0
- package/dist/plugins/repos.js +612 -0
- package/dist/plugins/repos.js.map +1 -0
- package/dist/plugins/supervisor.d.ts +50 -0
- package/dist/plugins/supervisor.js +125 -0
- package/dist/plugins/supervisor.js.map +1 -0
- package/dist/runtime/index.d.ts +28 -0
- package/dist/runtime/index.js +248 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/sandbox/index.d.ts +53 -0
- package/dist/sandbox/index.js +197 -0
- package/dist/sandbox/index.js.map +1 -0
- package/dist/server.d.ts +16 -0
- package/dist/server.js +36 -0
- package/dist/server.js.map +1 -0
- package/dist/settings.d.ts +43 -0
- package/dist/settings.js +99 -0
- package/dist/settings.js.map +1 -0
- package/dist/testing/index.d.ts +73 -0
- package/dist/testing/index.js +174 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +4 -0
- package/dist/version.js.map +1 -0
- package/drizzle/0000_init.sql +334 -0
- package/drizzle/0001_totp_last_step.sql +1 -0
- package/drizzle/0002_access_levels.sql +16 -0
- package/drizzle/0003_drop_exclusion_flags.sql +2 -0
- package/drizzle/0004_grant_instance_ids.sql +1 -0
- package/drizzle/0005_rule_strict_miss.sql +1 -0
- package/drizzle/0006_attestation_waived.sql +1 -0
- package/drizzle/meta/0000_snapshot.json +2318 -0
- package/drizzle/meta/0001_snapshot.json +2325 -0
- package/drizzle/meta/0002_snapshot.json +2348 -0
- package/drizzle/meta/0003_snapshot.json +2332 -0
- package/drizzle/meta/0004_snapshot.json +2339 -0
- package/drizzle/meta/0005_snapshot.json +2346 -0
- package/drizzle/meta/0006_snapshot.json +2354 -0
- package/drizzle/meta/_journal.json +55 -0
- package/package.json +58 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
/** Random opaque tokens (stored hashed) and short-lived HMAC-signed payloads for multi-step flows. */
|
|
3
|
+
export function randomToken(bytes = 32) {
|
|
4
|
+
return randomBytes(bytes).toString('base64url');
|
|
5
|
+
}
|
|
6
|
+
export function sha256(value) {
|
|
7
|
+
return createHash('sha256').update(value).digest('hex');
|
|
8
|
+
}
|
|
9
|
+
/** Signs a small JSON payload with an expiry. Used for the TOTP step, OIDC state and consent forms. */
|
|
10
|
+
export function signPayload(key, payload, ttlMs, nowMs = Date.now()) {
|
|
11
|
+
const body = Buffer.from(JSON.stringify({ ...payload, exp: nowMs + ttlMs })).toString('base64url');
|
|
12
|
+
const mac = createHmac('sha256', key).update(body).digest('base64url');
|
|
13
|
+
return `${body}.${mac}`;
|
|
14
|
+
}
|
|
15
|
+
export function verifyPayload(key, token, nowMs = Date.now()) {
|
|
16
|
+
if (!token)
|
|
17
|
+
return null;
|
|
18
|
+
const [body, mac] = token.split('.');
|
|
19
|
+
if (!body || !mac)
|
|
20
|
+
return null;
|
|
21
|
+
const expected = Buffer.from(createHmac('sha256', key).update(body).digest('base64url'));
|
|
22
|
+
const given = Buffer.from(mac);
|
|
23
|
+
if (expected.length !== given.length || !timingSafeEqual(expected, given))
|
|
24
|
+
return null;
|
|
25
|
+
try {
|
|
26
|
+
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
|
|
27
|
+
if (typeof payload.exp !== 'number' || payload.exp < nowMs)
|
|
28
|
+
return null;
|
|
29
|
+
return payload;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function safeEqual(a, b) {
|
|
36
|
+
const ab = Buffer.from(a);
|
|
37
|
+
const bb = Buffer.from(b);
|
|
38
|
+
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=tokens.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tokens.js","sourceRoot":"","sources":["../../src/auth/tokens.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAEnF,sGAAsG;AAEtG,MAAM,UAAU,WAAW,CAAC,KAAK,GAAG,EAAE;IACpC,OAAO,WAAW,CAAC,KAAK,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;AAClD,CAAC;AAED,MAAM,UAAU,MAAM,CAAC,KAAa;IAClC,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAC1D,CAAC;AAED,uGAAuG;AACvG,MAAM,UAAU,WAAW,CAAC,GAAW,EAAE,OAAgC,EAAE,KAAa,EAAE,KAAK,GAAG,IAAI,CAAC,GAAG,EAAE;IAC1G,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,GAAG,OAAO,EAAE,GAAG,EAAE,KAAK,GAAG,KAAK,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;IACnG,MAAM,GAAG,GAAG,UAAU,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC;IACvE,OAAO,GAAG,IAAI,IAAI,GAAG,EAAE,CAAC;AAC1B,CAAC;AAED,MAAM,UAAU,aAAa,CAC3B,GAAW,EACX,KAAyB,EACzB,KAAK,GAAG,IAAI,CAAC,GAAG,EAAE;IAElB,IAAI,CAAC,KAAK;QAAE,OAAO,IAAI,CAAC;IACxB,MAAM,CAAC,IAAI,EAAE,GAAG,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACrC,IAAI,CAAC,IAAI,IAAI,CAAC,GAAG;QAAE,OAAO,IAAI,CAAC;IAC/B,MAAM,QAAQ,GAAG,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,CAAC;IACzF,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC/B,IAAI,QAAQ,CAAC,MAAM,KAAK,KAAK,CAAC,MAAM,IAAI,CAAC,eAAe,CAAC,QAAQ,EAAE,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACvF,IAAI,CAAC;QACH,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAyB,CAAC;QACpG,IAAI,OAAO,OAAO,CAAC,GAAG,KAAK,QAAQ,IAAI,OAAO,CAAC,GAAG,GAAG,KAAK;YAAE,OAAO,IAAI,CAAC;QACxE,OAAO,OAAO,CAAC;IACjB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,CAAS,EAAE,CAAS;IAC5C,MAAM,EAAE,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC1B,MAAM,EAAE,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC1B,OAAO,EAAE,CAAC,MAAM,KAAK,EAAE,CAAC,MAAM,IAAI,eAAe,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;AAC5D,CAAC"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare function base32Encode(buf: Buffer): string;
|
|
2
|
+
export declare function base32Decode(input: string): Buffer;
|
|
3
|
+
export declare function generateTotpSecret(): string;
|
|
4
|
+
export declare function totpAt(secret: string, step: number): string;
|
|
5
|
+
export declare const currentStep: (nowMs?: number) => number;
|
|
6
|
+
/**
|
|
7
|
+
* Checks a code against the current step ±1 (clock drift) and returns the matched step, or null.
|
|
8
|
+
* Callers must reject steps ≤ the last accepted one to prevent replay.
|
|
9
|
+
*/
|
|
10
|
+
export declare function verifyTotp(secret: string, code: string, opts?: {
|
|
11
|
+
nowMs?: number;
|
|
12
|
+
lastStep?: number | null;
|
|
13
|
+
}): number | null;
|
|
14
|
+
export declare function otpauthUri(secret: string, account: string, issuer?: string): string;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
/** RFC 6238 TOTP (SHA-1, 6 digits, 30 s) — the parameters every authenticator app supports. */
|
|
3
|
+
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
4
|
+
const STEP_SECONDS = 30;
|
|
5
|
+
const DIGITS = 6;
|
|
6
|
+
export function base32Encode(buf) {
|
|
7
|
+
let bits = 0;
|
|
8
|
+
let value = 0;
|
|
9
|
+
let out = '';
|
|
10
|
+
for (const byte of buf) {
|
|
11
|
+
value = (value << 8) | byte;
|
|
12
|
+
bits += 8;
|
|
13
|
+
while (bits >= 5) {
|
|
14
|
+
out += ALPHABET[(value >>> (bits - 5)) & 31];
|
|
15
|
+
bits -= 5;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (bits > 0)
|
|
19
|
+
out += ALPHABET[(value << (5 - bits)) & 31];
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
export function base32Decode(input) {
|
|
23
|
+
const clean = input.toUpperCase().replace(/[\s=-]/g, '');
|
|
24
|
+
let bits = 0;
|
|
25
|
+
let value = 0;
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const ch of clean) {
|
|
28
|
+
const idx = ALPHABET.indexOf(ch);
|
|
29
|
+
if (idx === -1)
|
|
30
|
+
throw new Error('Invalid base32');
|
|
31
|
+
value = (value << 5) | idx;
|
|
32
|
+
bits += 5;
|
|
33
|
+
if (bits >= 8) {
|
|
34
|
+
out.push((value >>> (bits - 8)) & 0xff);
|
|
35
|
+
bits -= 8;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return Buffer.from(out);
|
|
39
|
+
}
|
|
40
|
+
export function generateTotpSecret() {
|
|
41
|
+
return base32Encode(randomBytes(20));
|
|
42
|
+
}
|
|
43
|
+
export function totpAt(secret, step) {
|
|
44
|
+
const counter = Buffer.alloc(8);
|
|
45
|
+
counter.writeBigUInt64BE(BigInt(step));
|
|
46
|
+
const mac = createHmac('sha1', base32Decode(secret)).update(counter).digest();
|
|
47
|
+
const offset = mac[mac.length - 1] & 0x0f;
|
|
48
|
+
const code = (mac.readUInt32BE(offset) & 0x7fffffff) % 10 ** DIGITS;
|
|
49
|
+
return code.toString().padStart(DIGITS, '0');
|
|
50
|
+
}
|
|
51
|
+
export const currentStep = (nowMs = Date.now()) => Math.floor(nowMs / 1000 / STEP_SECONDS);
|
|
52
|
+
/**
|
|
53
|
+
* Checks a code against the current step ±1 (clock drift) and returns the matched step, or null.
|
|
54
|
+
* Callers must reject steps ≤ the last accepted one to prevent replay.
|
|
55
|
+
*/
|
|
56
|
+
export function verifyTotp(secret, code, opts = {}) {
|
|
57
|
+
const normalized = code.replace(/\s/g, '');
|
|
58
|
+
if (!/^\d{6}$/.test(normalized))
|
|
59
|
+
return null;
|
|
60
|
+
const now = currentStep(opts.nowMs);
|
|
61
|
+
for (const step of [now - 1, now, now + 1]) {
|
|
62
|
+
if (opts.lastStep != null && step <= opts.lastStep)
|
|
63
|
+
continue;
|
|
64
|
+
const expected = Buffer.from(totpAt(secret, step));
|
|
65
|
+
if (timingSafeEqual(expected, Buffer.from(normalized)))
|
|
66
|
+
return step;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
export function otpauthUri(secret, account, issuer = 'Synoikia') {
|
|
71
|
+
const label = encodeURIComponent(`${issuer}:${account}`);
|
|
72
|
+
const params = new URLSearchParams({
|
|
73
|
+
secret,
|
|
74
|
+
issuer,
|
|
75
|
+
algorithm: 'SHA1',
|
|
76
|
+
digits: String(DIGITS),
|
|
77
|
+
period: String(STEP_SECONDS),
|
|
78
|
+
});
|
|
79
|
+
return `otpauth://totp/${label}?${params.toString()}`;
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=totp.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"totp.js","sourceRoot":"","sources":["../../src/auth/totp.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { SecretBox } from '../crypto/index.js';
|
|
2
|
+
import type { Db } from '../db/index.js';
|
|
3
|
+
import { users } from '../db/schema.js';
|
|
4
|
+
export type UserRow = typeof users.$inferSelect;
|
|
5
|
+
export interface PublicUser {
|
|
6
|
+
id: string;
|
|
7
|
+
username: string;
|
|
8
|
+
totpEnabled: boolean;
|
|
9
|
+
oidcLinked: boolean;
|
|
10
|
+
hasPassword: boolean;
|
|
11
|
+
disabled: boolean;
|
|
12
|
+
createdAt: Date;
|
|
13
|
+
lastLoginAt: Date | null;
|
|
14
|
+
}
|
|
15
|
+
export declare const toPublicUser: (u: UserRow) => PublicUser;
|
|
16
|
+
export declare function checkPasswordPolicy(password: string): void;
|
|
17
|
+
export declare class UserService {
|
|
18
|
+
private readonly db;
|
|
19
|
+
private readonly box;
|
|
20
|
+
constructor(db: Db, box: SecretBox);
|
|
21
|
+
count(): number;
|
|
22
|
+
list(): PublicUser[];
|
|
23
|
+
get(id: string): UserRow;
|
|
24
|
+
byUsername(username: string): UserRow | undefined;
|
|
25
|
+
byOidc(issuer: string, subject: string): UserRow | undefined;
|
|
26
|
+
create(input: {
|
|
27
|
+
username: string;
|
|
28
|
+
password?: string;
|
|
29
|
+
}, actor?: {
|
|
30
|
+
userId?: string;
|
|
31
|
+
}, opts?: {
|
|
32
|
+
onlyIfFirst?: boolean;
|
|
33
|
+
}): Promise<PublicUser>;
|
|
34
|
+
/** First-run setup: only while no users exist (design §6.1). */
|
|
35
|
+
setupFirstUser(username: string, password: string): Promise<PublicUser>;
|
|
36
|
+
/** `ADMIN_BOOTSTRAP_*`: create the first user on boot if none exist; ignored afterwards. */
|
|
37
|
+
bootstrap(username?: string, password?: string): Promise<'created' | 'ignored' | 'not_configured'>;
|
|
38
|
+
verifyPassword(username: string, password: string): Promise<UserRow | null>;
|
|
39
|
+
setPassword(userId: string, password: string, opts?: {
|
|
40
|
+
currentPassword?: string;
|
|
41
|
+
actorId?: string;
|
|
42
|
+
}): Promise<void>;
|
|
43
|
+
setDisabled(userId: string, disabled: boolean, actor?: {
|
|
44
|
+
userId?: string;
|
|
45
|
+
}): void;
|
|
46
|
+
markLogin(userId: string): void;
|
|
47
|
+
private totpSecret;
|
|
48
|
+
/** Starts (or restarts) enrollment. TOTP stays off until `confirmTotp` succeeds. */
|
|
49
|
+
beginTotp(userId: string): {
|
|
50
|
+
secret: string;
|
|
51
|
+
uri: string;
|
|
52
|
+
};
|
|
53
|
+
/** Confirms enrollment with a first code; returns the recovery codes, which are shown only once. */
|
|
54
|
+
confirmTotp(userId: string, code: string): string[];
|
|
55
|
+
resetTotp(userId: string, actor?: {
|
|
56
|
+
userId?: string;
|
|
57
|
+
}): void;
|
|
58
|
+
/**
|
|
59
|
+
* A TOTP code only (replay-protected), for checks that must not accept, or use up, a recovery code
|
|
60
|
+
* (the approval page).
|
|
61
|
+
*/
|
|
62
|
+
verifyTotpCode(userId: string, code: string): boolean;
|
|
63
|
+
/** Second factor: a TOTP code (replay-protected) or a single-use recovery code. */
|
|
64
|
+
verifySecondFactor(userId: string, code: string): 'totp' | 'recovery' | null;
|
|
65
|
+
linkOidc(userId: string, issuer: string, subject: string): void;
|
|
66
|
+
unlinkOidc(userId: string, actor?: {
|
|
67
|
+
userId?: string;
|
|
68
|
+
}): void;
|
|
69
|
+
hasOidcLinkedUser(except?: string): boolean;
|
|
70
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { hash, verify } from '@node-rs/argon2';
|
|
3
|
+
import { and, asc, count, eq } from 'drizzle-orm';
|
|
4
|
+
import { writeAudit } from '../audit.js';
|
|
5
|
+
import { aad } from '../crypto/index.js';
|
|
6
|
+
import { users } from '../db/schema.js';
|
|
7
|
+
import { ConflictError, NotFoundError, ValidationError } from '../errors.js';
|
|
8
|
+
import { randomToken, sha256 } from './tokens.js';
|
|
9
|
+
import { generateTotpSecret, otpauthUri, verifyTotp } from './totp.js';
|
|
10
|
+
/**
|
|
11
|
+
* Portal accounts (design §6.1): local users with argon2id passwords, optional TOTP with recovery
|
|
12
|
+
* codes, optional OIDC link. No roles — every user is an admin.
|
|
13
|
+
*/
|
|
14
|
+
// argon2id, m = 64 MiB, t = 3, p = 1 (design §6.1). @node-rs/argon2 defaults to argon2id.
|
|
15
|
+
const ARGON = { memoryCost: 65536, timeCost: 3, parallelism: 1 };
|
|
16
|
+
const USERNAME = /^[a-zA-Z0-9._@-]{2,64}$/;
|
|
17
|
+
const RECOVERY_CODES = 10;
|
|
18
|
+
export const toPublicUser = (u) => ({
|
|
19
|
+
id: u.id,
|
|
20
|
+
username: u.username,
|
|
21
|
+
totpEnabled: u.totpEnabled,
|
|
22
|
+
oidcLinked: !!u.oidcSubject,
|
|
23
|
+
hasPassword: !!u.passwordHash,
|
|
24
|
+
disabled: u.disabled,
|
|
25
|
+
createdAt: u.createdAt,
|
|
26
|
+
lastLoginAt: u.lastLoginAt,
|
|
27
|
+
});
|
|
28
|
+
export function checkPasswordPolicy(password) {
|
|
29
|
+
if (typeof password !== 'string' || password.length < 12) {
|
|
30
|
+
throw new ValidationError('weak_password', 'Passwords must be at least 12 characters');
|
|
31
|
+
}
|
|
32
|
+
if (password.length > 1024)
|
|
33
|
+
throw new ValidationError('weak_password', 'Password is too long');
|
|
34
|
+
}
|
|
35
|
+
// Verified when the username doesn't exist, so response timing doesn't reveal which usernames exist.
|
|
36
|
+
let dummyHash;
|
|
37
|
+
export class UserService {
|
|
38
|
+
db;
|
|
39
|
+
box;
|
|
40
|
+
constructor(db, box) {
|
|
41
|
+
this.db = db;
|
|
42
|
+
this.box = box;
|
|
43
|
+
}
|
|
44
|
+
count() {
|
|
45
|
+
return this.db.select({ n: count() }).from(users).get()?.n ?? 0;
|
|
46
|
+
}
|
|
47
|
+
list() {
|
|
48
|
+
return this.db.select().from(users).orderBy(asc(users.username)).all().map(toPublicUser);
|
|
49
|
+
}
|
|
50
|
+
get(id) {
|
|
51
|
+
const u = this.db.select().from(users).where(eq(users.id, id)).get();
|
|
52
|
+
if (!u)
|
|
53
|
+
throw new NotFoundError('user_not_found', 'No such user');
|
|
54
|
+
return u;
|
|
55
|
+
}
|
|
56
|
+
byUsername(username) {
|
|
57
|
+
return this.db.select().from(users).where(eq(users.username, username)).get();
|
|
58
|
+
}
|
|
59
|
+
byOidc(issuer, subject) {
|
|
60
|
+
return this.db
|
|
61
|
+
.select()
|
|
62
|
+
.from(users)
|
|
63
|
+
.where(and(eq(users.oidcIssuer, issuer), eq(users.oidcSubject, subject)))
|
|
64
|
+
.get();
|
|
65
|
+
}
|
|
66
|
+
async create(input, actor = {}, opts = {}) {
|
|
67
|
+
const username = input.username.trim();
|
|
68
|
+
if (!USERNAME.test(username)) {
|
|
69
|
+
throw new ValidationError('invalid_username', 'Usernames are 2–64 letters, digits, dots, dashes, underscores or @');
|
|
70
|
+
}
|
|
71
|
+
if (this.byUsername(username))
|
|
72
|
+
throw new ConflictError('username_taken', 'That username is taken');
|
|
73
|
+
if (input.password !== undefined)
|
|
74
|
+
checkPasswordPolicy(input.password);
|
|
75
|
+
const row = {
|
|
76
|
+
id: randomUUID(),
|
|
77
|
+
username,
|
|
78
|
+
passwordHash: input.password ? await hash(input.password, ARGON) : null,
|
|
79
|
+
createdAt: new Date(),
|
|
80
|
+
};
|
|
81
|
+
this.db.transaction((tx) => {
|
|
82
|
+
// Re-checked after the (slow) hash, in the same transaction as the insert: of two concurrent
|
|
83
|
+
// first-run setups only one creates a user.
|
|
84
|
+
if (opts.onlyIfFirst && (tx.select({ n: count() }).from(users).get()?.n ?? 0) > 0)
|
|
85
|
+
throw new ConflictError('setup_done', 'Setup has already been completed');
|
|
86
|
+
if (tx.select({ id: users.id }).from(users).where(eq(users.username, username)).get())
|
|
87
|
+
throw new ConflictError('username_taken', 'That username is taken');
|
|
88
|
+
tx.insert(users).values(row).run();
|
|
89
|
+
writeAudit(tx, {
|
|
90
|
+
kind: 'auth',
|
|
91
|
+
decision: 'user_created',
|
|
92
|
+
actorKind: actor.userId ? 'user' : 'system',
|
|
93
|
+
actorId: actor.userId,
|
|
94
|
+
detail: { username },
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
return toPublicUser(this.get(row.id));
|
|
98
|
+
}
|
|
99
|
+
/** First-run setup: only while no users exist (design §6.1). */
|
|
100
|
+
async setupFirstUser(username, password) {
|
|
101
|
+
if (this.count() > 0)
|
|
102
|
+
throw new ConflictError('setup_done', 'Setup has already been completed');
|
|
103
|
+
return this.create({ username, password }, {}, { onlyIfFirst: true });
|
|
104
|
+
}
|
|
105
|
+
/** `ADMIN_BOOTSTRAP_*`: create the first user on boot if none exist; ignored afterwards. */
|
|
106
|
+
async bootstrap(username, password) {
|
|
107
|
+
if (!username || !password)
|
|
108
|
+
return 'not_configured';
|
|
109
|
+
if (this.count() > 0)
|
|
110
|
+
return 'ignored';
|
|
111
|
+
await this.create({ username, password }, {}, { onlyIfFirst: true });
|
|
112
|
+
return 'created';
|
|
113
|
+
}
|
|
114
|
+
async verifyPassword(username, password) {
|
|
115
|
+
const user = this.byUsername(username);
|
|
116
|
+
if (!user?.passwordHash || user.disabled) {
|
|
117
|
+
dummyHash ??= hash(randomToken(), ARGON);
|
|
118
|
+
await verify(await dummyHash, password).catch(() => false);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
return (await verify(user.passwordHash, password).catch(() => false)) ? user : null;
|
|
122
|
+
}
|
|
123
|
+
async setPassword(userId, password, opts = {}) {
|
|
124
|
+
const user = this.get(userId);
|
|
125
|
+
if (opts.currentPassword !== undefined && user.passwordHash) {
|
|
126
|
+
if (!(await verify(user.passwordHash, opts.currentPassword).catch(() => false))) {
|
|
127
|
+
throw new ValidationError('wrong_password', 'Current password is incorrect');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
checkPasswordPolicy(password);
|
|
131
|
+
const passwordHash = await hash(password, ARGON);
|
|
132
|
+
this.db.transaction((tx) => {
|
|
133
|
+
tx.update(users).set({ passwordHash }).where(eq(users.id, userId)).run();
|
|
134
|
+
writeAudit(tx, {
|
|
135
|
+
kind: 'auth',
|
|
136
|
+
decision: 'password_changed',
|
|
137
|
+
actorKind: 'user',
|
|
138
|
+
actorId: opts.actorId ?? userId,
|
|
139
|
+
detail: { username: user.username },
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
setDisabled(userId, disabled, actor = {}) {
|
|
144
|
+
const user = this.get(userId);
|
|
145
|
+
if (disabled) {
|
|
146
|
+
const active = this.db.select().from(users).where(eq(users.disabled, false)).all();
|
|
147
|
+
if (active.length === 1 && active[0].id === userId) {
|
|
148
|
+
throw new ConflictError('last_user', 'You cannot disable the last active user');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
this.db.transaction((tx) => {
|
|
152
|
+
tx.update(users).set({ disabled }).where(eq(users.id, userId)).run();
|
|
153
|
+
writeAudit(tx, {
|
|
154
|
+
kind: 'auth',
|
|
155
|
+
decision: disabled ? 'user_disabled' : 'user_enabled',
|
|
156
|
+
actorKind: 'user',
|
|
157
|
+
actorId: actor.userId,
|
|
158
|
+
detail: { username: user.username },
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
markLogin(userId) {
|
|
163
|
+
this.db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, userId)).run();
|
|
164
|
+
}
|
|
165
|
+
// ── TOTP ─────────────────────────────────────────────────────────────────────────────────────────
|
|
166
|
+
totpSecret(user) {
|
|
167
|
+
if (!user.totpSecretEnc)
|
|
168
|
+
return null;
|
|
169
|
+
return this.box.decrypt(user.totpSecretEnc, aad('users', 'totp_secret_enc', user.id)).toString('utf8');
|
|
170
|
+
}
|
|
171
|
+
/** Starts (or restarts) enrollment. TOTP stays off until `confirmTotp` succeeds. */
|
|
172
|
+
beginTotp(userId) {
|
|
173
|
+
const user = this.get(userId);
|
|
174
|
+
if (user.totpEnabled)
|
|
175
|
+
throw new ConflictError('totp_enabled', 'TOTP is already enabled; reset it first');
|
|
176
|
+
const secret = generateTotpSecret();
|
|
177
|
+
this.db
|
|
178
|
+
.update(users)
|
|
179
|
+
.set({ totpSecretEnc: this.box.encrypt(secret, aad('users', 'totp_secret_enc', userId)), totpLastStep: null })
|
|
180
|
+
.where(eq(users.id, userId))
|
|
181
|
+
.run();
|
|
182
|
+
return { secret, uri: otpauthUri(secret, user.username) };
|
|
183
|
+
}
|
|
184
|
+
/** Confirms enrollment with a first code; returns the recovery codes, which are shown only once. */
|
|
185
|
+
confirmTotp(userId, code) {
|
|
186
|
+
const user = this.get(userId);
|
|
187
|
+
const secret = this.totpSecret(user);
|
|
188
|
+
if (!secret || user.totpEnabled)
|
|
189
|
+
throw new ConflictError('totp_not_pending', 'Start TOTP enrollment first');
|
|
190
|
+
const step = verifyTotp(secret, code);
|
|
191
|
+
if (step === null)
|
|
192
|
+
throw new ValidationError('invalid_code', 'That code is not valid');
|
|
193
|
+
const codes = Array.from({ length: RECOVERY_CODES }, () => randomToken(8).replace(/[-_]/g, '').slice(0, 10).toLowerCase());
|
|
194
|
+
this.db.transaction((tx) => {
|
|
195
|
+
tx.update(users)
|
|
196
|
+
.set({ totpEnabled: true, totpLastStep: step, recoveryCodesHash: codes.map(sha256) })
|
|
197
|
+
.where(eq(users.id, userId))
|
|
198
|
+
.run();
|
|
199
|
+
writeAudit(tx, {
|
|
200
|
+
kind: 'auth',
|
|
201
|
+
decision: 'totp_enabled',
|
|
202
|
+
actorKind: 'user',
|
|
203
|
+
actorId: userId,
|
|
204
|
+
detail: { username: user.username },
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
return codes;
|
|
208
|
+
}
|
|
209
|
+
resetTotp(userId, actor = {}) {
|
|
210
|
+
const user = this.get(userId);
|
|
211
|
+
this.db.transaction((tx) => {
|
|
212
|
+
tx.update(users)
|
|
213
|
+
.set({ totpEnabled: false, totpSecretEnc: null, totpLastStep: null, recoveryCodesHash: null })
|
|
214
|
+
.where(eq(users.id, userId))
|
|
215
|
+
.run();
|
|
216
|
+
writeAudit(tx, {
|
|
217
|
+
kind: 'auth',
|
|
218
|
+
decision: 'totp_reset',
|
|
219
|
+
actorKind: 'user',
|
|
220
|
+
actorId: actor.userId ?? userId,
|
|
221
|
+
detail: { username: user.username },
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* A TOTP code only (replay-protected), for checks that must not accept, or use up, a recovery code
|
|
227
|
+
* (the approval page).
|
|
228
|
+
*/
|
|
229
|
+
verifyTotpCode(userId, code) {
|
|
230
|
+
const user = this.get(userId);
|
|
231
|
+
const secret = this.totpSecret(user);
|
|
232
|
+
if (!user.totpEnabled || !secret)
|
|
233
|
+
return false;
|
|
234
|
+
const step = verifyTotp(secret, code, { lastStep: user.totpLastStep });
|
|
235
|
+
if (step === null)
|
|
236
|
+
return false;
|
|
237
|
+
this.db.update(users).set({ totpLastStep: step }).where(eq(users.id, userId)).run();
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
/** Second factor: a TOTP code (replay-protected) or a single-use recovery code. */
|
|
241
|
+
verifySecondFactor(userId, code) {
|
|
242
|
+
const user = this.get(userId);
|
|
243
|
+
if (!user.totpEnabled || !this.totpSecret(user))
|
|
244
|
+
return null;
|
|
245
|
+
if (this.verifyTotpCode(userId, code))
|
|
246
|
+
return 'totp';
|
|
247
|
+
const hashed = sha256(code.trim().toLowerCase());
|
|
248
|
+
const remaining = user.recoveryCodesHash ?? [];
|
|
249
|
+
if (remaining.includes(hashed)) {
|
|
250
|
+
this.db
|
|
251
|
+
.update(users)
|
|
252
|
+
.set({ recoveryCodesHash: remaining.filter((h) => h !== hashed) })
|
|
253
|
+
.where(eq(users.id, userId))
|
|
254
|
+
.run();
|
|
255
|
+
writeAudit(this.db, {
|
|
256
|
+
kind: 'auth',
|
|
257
|
+
decision: 'recovery_code_used',
|
|
258
|
+
actorKind: 'user',
|
|
259
|
+
actorId: userId,
|
|
260
|
+
detail: { remaining: remaining.length - 1 },
|
|
261
|
+
});
|
|
262
|
+
return 'recovery';
|
|
263
|
+
}
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
// ── OIDC links ───────────────────────────────────────────────────────────────────────────────────
|
|
267
|
+
linkOidc(userId, issuer, subject) {
|
|
268
|
+
const existing = this.byOidc(issuer, subject);
|
|
269
|
+
if (existing && existing.id !== userId)
|
|
270
|
+
throw new ConflictError('oidc_taken', 'That identity is linked to another user');
|
|
271
|
+
this.db.transaction((tx) => {
|
|
272
|
+
tx.update(users).set({ oidcIssuer: issuer, oidcSubject: subject }).where(eq(users.id, userId)).run();
|
|
273
|
+
writeAudit(tx, {
|
|
274
|
+
kind: 'auth',
|
|
275
|
+
decision: 'oidc_linked',
|
|
276
|
+
actorKind: 'user',
|
|
277
|
+
actorId: userId,
|
|
278
|
+
detail: { issuer, subject },
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
unlinkOidc(userId, actor = {}) {
|
|
283
|
+
this.db.transaction((tx) => {
|
|
284
|
+
tx.update(users).set({ oidcIssuer: null, oidcSubject: null }).where(eq(users.id, userId)).run();
|
|
285
|
+
writeAudit(tx, { kind: 'auth', decision: 'oidc_unlinked', actorKind: 'user', actorId: actor.userId ?? userId });
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
hasOidcLinkedUser(except) {
|
|
289
|
+
return this.db
|
|
290
|
+
.select()
|
|
291
|
+
.from(users)
|
|
292
|
+
.all()
|
|
293
|
+
.some((u) => !!u.oidcSubject && !u.disabled && u.id !== except);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
//# sourceMappingURL=users.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"users.js","sourceRoot":"","sources":["../../src/auth/users.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Db, DbLike } from '../db/index.js';
|
|
2
|
+
import { operations } from '../db/schema.js';
|
|
3
|
+
import type { AccessDecision, AccessLevel, AccessPrincipal } from '../gate/access.js';
|
|
4
|
+
type OperationRow = typeof operations.$inferSelect;
|
|
5
|
+
export interface Actor {
|
|
6
|
+
userId?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare const accessInput: (op: OperationRow) => {
|
|
9
|
+
classification: "read" | "write";
|
|
10
|
+
locked: boolean;
|
|
11
|
+
levelOverride: "none" | "read" | "ask" | "write" | null;
|
|
12
|
+
writeAcknowledged: boolean;
|
|
13
|
+
};
|
|
14
|
+
export interface GroupSummary {
|
|
15
|
+
key: string;
|
|
16
|
+
label: string;
|
|
17
|
+
level: AccessLevel;
|
|
18
|
+
stale: boolean;
|
|
19
|
+
counts: {
|
|
20
|
+
read: number;
|
|
21
|
+
write: number;
|
|
22
|
+
locked: number;
|
|
23
|
+
pendingReview: number;
|
|
24
|
+
overridden: number;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export declare function listGroups(db: DbLike, instanceId: string): GroupSummary[];
|
|
28
|
+
/** Access decision for one operation key; unknown or stale operations are unreachable. */
|
|
29
|
+
export declare function resolveAccess(db: DbLike, instanceId: string, key: string, principal?: AccessPrincipal): AccessDecision | {
|
|
30
|
+
reachable: false;
|
|
31
|
+
reason: 'unknown_operation';
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Sets one group's level; operations with their own level keep it. Raising to `write` must carry
|
|
35
|
+
* `acknowledge` = exactly the writes that will then run without asking (the list the admin was
|
|
36
|
+
* shown); if a sync changed that list in between, this fails with 409 and the fresh list.
|
|
37
|
+
*/
|
|
38
|
+
export declare function setGroupLevel(db: Db, instanceId: string, key: string, level: string, opts?: {
|
|
39
|
+
acknowledge?: string[];
|
|
40
|
+
actor?: Actor;
|
|
41
|
+
now?: Date;
|
|
42
|
+
}): GroupSummary;
|
|
43
|
+
export interface BulkPreview {
|
|
44
|
+
level: AccessLevel;
|
|
45
|
+
/** `exposes`: writes that will run without asking at `write` (only listed for a `write` preview). */
|
|
46
|
+
groups: {
|
|
47
|
+
key: string;
|
|
48
|
+
label: string;
|
|
49
|
+
from: AccessLevel;
|
|
50
|
+
exposes: {
|
|
51
|
+
id: string;
|
|
52
|
+
key: string;
|
|
53
|
+
}[];
|
|
54
|
+
}[];
|
|
55
|
+
/** Pass back unchanged as `acknowledge` when applying a bulk `write`. */
|
|
56
|
+
acknowledge: string[];
|
|
57
|
+
}
|
|
58
|
+
export declare function previewBulkLevel(db: DbLike, instanceId: string, level: string): BulkPreview;
|
|
59
|
+
/**
|
|
60
|
+
* Sets every group of an instance to one level (operations with their own level keep it).
|
|
61
|
+
* `none`/`read`/`ask` apply directly. `write` requires `confirm` = the instance slug and
|
|
62
|
+
* `acknowledge` = the current preview's list. Locked operations never auto-run.
|
|
63
|
+
*/
|
|
64
|
+
export declare function applyBulkLevel(db: Db, instanceId: string, level: string, opts?: {
|
|
65
|
+
confirm?: string;
|
|
66
|
+
acknowledge?: string[];
|
|
67
|
+
actor?: Actor;
|
|
68
|
+
now?: Date;
|
|
69
|
+
}): BulkPreview['groups'];
|
|
70
|
+
export declare function renameGroup(db: Db, instanceId: string, key: string, label: string, opts?: {
|
|
71
|
+
actor?: Actor;
|
|
72
|
+
}): void;
|
|
73
|
+
/**
|
|
74
|
+
* Merges groups into `into` (created if new). Stored as aliases so future syncs keep the grouping.
|
|
75
|
+
* The merged group takes the lowest level involved, so merging never widens access.
|
|
76
|
+
*/
|
|
77
|
+
export declare function mergeGroups(db: Db, instanceId: string, input: {
|
|
78
|
+
from: string[];
|
|
79
|
+
into: string;
|
|
80
|
+
label?: string;
|
|
81
|
+
}, opts?: {
|
|
82
|
+
actor?: Actor;
|
|
83
|
+
now?: Date;
|
|
84
|
+
}): GroupSummary;
|
|
85
|
+
export interface OperationPatch {
|
|
86
|
+
/** The operation's own level; `null` makes it follow its group again. */
|
|
87
|
+
level?: AccessLevel | null;
|
|
88
|
+
acknowledged?: boolean;
|
|
89
|
+
classification?: 'read' | 'write';
|
|
90
|
+
/** Require a best-practice key (guides) for this operation; turning it off is remembered across syncs. */
|
|
91
|
+
attestationRequired?: boolean;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Per-operation level, acknowledgement and classification override. Locked operations can't be set
|
|
95
|
+
* to `write` and can't change classification (409). Setting a write to `write`, or overriding an
|
|
96
|
+
* operation's classification to write, acknowledges it.
|
|
97
|
+
*/
|
|
98
|
+
export declare function updateOperation(db: Db, instanceId: string, opId: string, patch: OperationPatch, opts?: {
|
|
99
|
+
actor?: Actor;
|
|
100
|
+
now?: Date;
|
|
101
|
+
}): void;
|
|
102
|
+
export {};
|