@hivemagnus/ardent-studio 0.0.0-stage → 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.
Files changed (159) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +12 -0
  3. package/README.md +133 -2
  4. package/dist/agents/config-edit.d.ts +20 -0
  5. package/dist/agents/config-edit.js +75 -0
  6. package/dist/agents/directory.d.ts +27 -0
  7. package/dist/agents/directory.js +97 -0
  8. package/dist/agents/operations.d.ts +47 -0
  9. package/dist/agents/operations.js +93 -0
  10. package/dist/auth/links.d.ts +27 -0
  11. package/dist/auth/links.js +46 -0
  12. package/dist/auth/oathweave.d.ts +46 -0
  13. package/dist/auth/oathweave.js +123 -0
  14. package/dist/blobs/store.d.ts +45 -0
  15. package/dist/blobs/store.js +159 -0
  16. package/dist/clients/chattermost.d.ts +16 -0
  17. package/dist/clients/chattermost.js +36 -0
  18. package/dist/clients/hivemem.d.ts +85 -0
  19. package/dist/clients/hivemem.js +89 -0
  20. package/dist/clients/magnus.d.ts +851 -0
  21. package/dist/clients/magnus.js +541 -0
  22. package/dist/clients/service-token.d.ts +24 -0
  23. package/dist/clients/service-token.js +87 -0
  24. package/dist/clients/sse.d.ts +8 -0
  25. package/dist/clients/sse.js +42 -0
  26. package/dist/conductor/activity.d.ts +38 -0
  27. package/dist/conductor/activity.js +101 -0
  28. package/dist/conductor/addressing.d.ts +24 -0
  29. package/dist/conductor/addressing.js +42 -0
  30. package/dist/conductor/conductor.d.ts +225 -0
  31. package/dist/conductor/conductor.js +939 -0
  32. package/dist/conductor/follower.d.ts +48 -0
  33. package/dist/conductor/follower.js +154 -0
  34. package/dist/conductor/live.d.ts +23 -0
  35. package/dist/conductor/live.js +32 -0
  36. package/dist/conductor/prompting.d.ts +74 -0
  37. package/dist/conductor/prompting.js +177 -0
  38. package/dist/config.d.ts +52 -0
  39. package/dist/config.js +163 -0
  40. package/dist/db/database.d.ts +12 -0
  41. package/dist/db/database.js +62 -0
  42. package/dist/db/json.d.ts +8 -0
  43. package/dist/db/json.js +13 -0
  44. package/dist/db/migrations.d.ts +6 -0
  45. package/dist/db/migrations.js +571 -0
  46. package/dist/db/repos/agents.d.ts +86 -0
  47. package/dist/db/repos/agents.js +92 -0
  48. package/dist/db/repos/audit.d.ts +37 -0
  49. package/dist/db/repos/audit.js +20 -0
  50. package/dist/db/repos/events.d.ts +29 -0
  51. package/dist/db/repos/events.js +55 -0
  52. package/dist/db/repos/files.d.ts +41 -0
  53. package/dist/db/repos/files.js +72 -0
  54. package/dist/db/repos/idempotency.d.ts +27 -0
  55. package/dist/db/repos/idempotency.js +46 -0
  56. package/dist/db/repos/links.d.ts +47 -0
  57. package/dist/db/repos/links.js +99 -0
  58. package/dist/db/repos/projects.d.ts +44 -0
  59. package/dist/db/repos/projects.js +107 -0
  60. package/dist/db/repos/proposals.d.ts +46 -0
  61. package/dist/db/repos/proposals.js +65 -0
  62. package/dist/db/repos/review.d.ts +191 -0
  63. package/dist/db/repos/review.js +195 -0
  64. package/dist/db/repos/room.d.ts +163 -0
  65. package/dist/db/repos/room.js +151 -0
  66. package/dist/db/repos/sessions.d.ts +36 -0
  67. package/dist/db/repos/sessions.js +30 -0
  68. package/dist/db/repos/summaries.d.ts +22 -0
  69. package/dist/db/repos/summaries.js +105 -0
  70. package/dist/db/repos/tasks.d.ts +103 -0
  71. package/dist/db/repos/tasks.js +112 -0
  72. package/dist/db/repos/team.d.ts +49 -0
  73. package/dist/db/repos/team.js +95 -0
  74. package/dist/db/repos/templates.d.ts +55 -0
  75. package/dist/db/repos/templates.js +120 -0
  76. package/dist/db/store.d.ts +57 -0
  77. package/dist/db/store.js +108 -0
  78. package/dist/events/bus.d.ts +21 -0
  79. package/dist/events/bus.js +31 -0
  80. package/dist/events/types.d.ts +3 -0
  81. package/dist/events/types.js +1 -0
  82. package/dist/ids.d.ts +28 -0
  83. package/dist/ids.js +81 -0
  84. package/dist/index.d.ts +18 -0
  85. package/dist/index.js +17 -0
  86. package/dist/knowledge/extract.d.ts +17 -0
  87. package/dist/knowledge/extract.js +82 -0
  88. package/dist/knowledge/sync.d.ts +40 -0
  89. package/dist/knowledge/sync.js +172 -0
  90. package/dist/logger.d.ts +5 -0
  91. package/dist/logger.js +11 -0
  92. package/dist/main.d.ts +2 -0
  93. package/dist/main.js +42 -0
  94. package/dist/review/anchors.d.ts +16 -0
  95. package/dist/review/anchors.js +67 -0
  96. package/dist/review/context.d.ts +4 -0
  97. package/dist/review/context.js +29 -0
  98. package/dist/review/docx.d.ts +29 -0
  99. package/dist/review/docx.js +21 -0
  100. package/dist/review/export.d.ts +16 -0
  101. package/dist/review/export.js +84 -0
  102. package/dist/review/importer.d.ts +68 -0
  103. package/dist/review/importer.js +215 -0
  104. package/dist/review/index.d.ts +13 -0
  105. package/dist/review/index.js +7 -0
  106. package/dist/server/app.d.ts +5 -0
  107. package/dist/server/app.js +44 -0
  108. package/dist/server/deps.d.ts +43 -0
  109. package/dist/server/deps.js +17 -0
  110. package/dist/server/env.d.ts +6 -0
  111. package/dist/server/env.js +1 -0
  112. package/dist/server/errors.d.ts +12 -0
  113. package/dist/server/errors.js +25 -0
  114. package/dist/server/routes/agent-studio.d.ts +5 -0
  115. package/dist/server/routes/agent-studio.js +441 -0
  116. package/dist/server/routes/agents.d.ts +3 -0
  117. package/dist/server/routes/agents.js +4 -0
  118. package/dist/server/routes/files.d.ts +3 -0
  119. package/dist/server/routes/files.js +88 -0
  120. package/dist/server/routes/health.d.ts +3 -0
  121. package/dist/server/routes/health.js +31 -0
  122. package/dist/server/routes/links.d.ts +3 -0
  123. package/dist/server/routes/links.js +79 -0
  124. package/dist/server/routes/projects.d.ts +3 -0
  125. package/dist/server/routes/projects.js +119 -0
  126. package/dist/server/routes/review.d.ts +3 -0
  127. package/dist/server/routes/review.js +198 -0
  128. package/dist/server/routes/room.d.ts +9 -0
  129. package/dist/server/routes/room.js +224 -0
  130. package/dist/server/routes/stream.d.ts +4 -0
  131. package/dist/server/routes/stream.js +104 -0
  132. package/dist/server/routes/team.d.ts +3 -0
  133. package/dist/server/routes/team.js +183 -0
  134. package/dist/server/routes/templates.d.ts +11 -0
  135. package/dist/server/routes/templates.js +103 -0
  136. package/dist/server/routes/work.d.ts +3 -0
  137. package/dist/server/routes/work.js +139 -0
  138. package/dist/server/routing.d.ts +31 -0
  139. package/dist/server/routing.js +132 -0
  140. package/dist/service.d.ts +34 -0
  141. package/dist/service.js +93 -0
  142. package/dist/shared/browser.d.ts +5824 -0
  143. package/dist/shared/browser.js +1318 -0
  144. package/dist/team/proposals.d.ts +129 -0
  145. package/dist/team/proposals.js +349 -0
  146. package/dist/team/service.d.ts +12 -0
  147. package/dist/team/service.js +13 -0
  148. package/dist/team/validation.d.ts +37 -0
  149. package/dist/team/validation.js +157 -0
  150. package/dist/templates/secrets.d.ts +2 -0
  151. package/dist/templates/secrets.js +23 -0
  152. package/dist/templates/seeds.d.ts +34 -0
  153. package/dist/templates/seeds.js +277 -0
  154. package/dist/url.d.ts +2 -0
  155. package/dist/url.js +7 -0
  156. package/dist/version.d.ts +2 -0
  157. package/dist/version.js +11 -0
  158. package/docs/openapi-browser.json +12658 -0
  159. package/package.json +91 -4
@@ -0,0 +1,93 @@
1
+ // Relays Magnus New Agent operations to the project that started them. Magnus runs the
2
+ // operation (profile, identity, HiveMem, model, skills, start, ready probe); Studio only mirrors
3
+ // its step receipts as `agent.operation.updated` events, from Magnus's operation stream, or by
4
+ // polling when the stream fails. Running operations are picked up again after a restart.
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+ import { agentOperationSchema } from '../clients/magnus.js';
7
+ /** An operation as the browser sees it. */
8
+ export function operationView(record, operation) {
9
+ return {
10
+ id: record.id, agent_ref: record.agent_ref, project_id: record.project_id, role_key: record.role_key,
11
+ kind: operation?.kind ?? 'deploy', status: operation?.status ?? record.status,
12
+ steps: (operation?.steps ?? []).map(step => ({ name: step.name, status: step.status, started_at: step.startedAt, finished_at: step.finishedAt, receipt: step.receipt, error: step.error })),
13
+ error: operation?.error ?? null, attempts: operation?.attempts ?? 0, created_by: record.created_by,
14
+ created_at: record.created_at, updated_at: operation?.updatedAt ?? record.updated_at, completed_at: operation?.completedAt ?? null,
15
+ };
16
+ }
17
+ export class OperationRelay {
18
+ store;
19
+ magnus;
20
+ logger;
21
+ options;
22
+ running = new Map();
23
+ stopped = false;
24
+ constructor(store, magnus, logger, options = {}) {
25
+ this.store = store;
26
+ this.magnus = magnus;
27
+ this.logger = logger;
28
+ this.options = options;
29
+ }
30
+ start() {
31
+ this.stopped = false;
32
+ for (const record of this.store.agents.runningOperations())
33
+ this.follow(record.id);
34
+ }
35
+ async stop() {
36
+ this.stopped = true;
37
+ for (const controller of this.running.values())
38
+ controller.abort();
39
+ this.running.clear();
40
+ }
41
+ follow(id) {
42
+ if (this.stopped || this.running.has(id))
43
+ return;
44
+ const controller = new AbortController();
45
+ this.running.set(id, controller);
46
+ void this.run(id, controller.signal).catch(error => this.logger.warn('Agent operation relay stopped', { operation_id: id, error: error instanceof Error ? error.message.slice(0, 200) : 'unknown' }))
47
+ .finally(() => { if (this.running.get(id) === controller)
48
+ this.running.delete(id); });
49
+ }
50
+ /** Store and publish Magnus's view of an operation. True once it has finished. */
51
+ apply(id, operation) {
52
+ const record = this.store.agents.operation(id);
53
+ if (!record)
54
+ return true;
55
+ this.store.tx(() => {
56
+ if (!this.store.agents.updateOperation(id, operation.status, operation))
57
+ return;
58
+ const updated = this.store.agents.operation(id);
59
+ if (updated.project_id)
60
+ this.store.emit(updated.project_id, 'agent.operation.updated', { operation: operationView(updated, operation) });
61
+ });
62
+ const finished = operation.status !== 'running';
63
+ if (finished)
64
+ this.options.onFinished?.(operation);
65
+ return finished;
66
+ }
67
+ async run(id, signal) {
68
+ let polls = 0;
69
+ while (!signal.aborted && polls < (this.options.maxPolls ?? 720)) {
70
+ try {
71
+ for await (const message of this.magnus.streamAgentOperation(id, { signal })) {
72
+ if (message.event !== 'operation' && message.event !== 'done')
73
+ continue;
74
+ const parsed = agentOperationSchema.safeParse(JSON.parse(message.data));
75
+ if (parsed.success && this.apply(id, parsed.data))
76
+ return;
77
+ }
78
+ }
79
+ catch {
80
+ if (signal.aborted)
81
+ return;
82
+ }
83
+ // The stream ended or failed before the operation finished: read it, then try again.
84
+ try {
85
+ if (this.apply(id, await this.magnus.agentOperation(id, { signal })))
86
+ return;
87
+ }
88
+ catch { /* Magnus unreachable: keep the last known state and retry. */ }
89
+ polls++;
90
+ await delay(this.options.pollMs ?? 5_000, undefined, { signal }).catch(() => undefined);
91
+ }
92
+ }
93
+ }
@@ -0,0 +1,27 @@
1
+ import { z } from 'zod';
2
+ import { type ArdentStudioPermission } from '../shared/browser.js';
3
+ export declare class IdentityLinkError extends Error {
4
+ name: string;
5
+ }
6
+ declare const linkSchema: z.ZodObject<{
7
+ issuer: z.ZodURL;
8
+ subject: z.ZodString;
9
+ localUserId: z.ZodString;
10
+ displayName: z.ZodOptional<z.ZodString>;
11
+ permissions: z.ZodOptional<z.ZodArray<z.ZodEnum<{
12
+ "ardentstudio:projects:read": "ardentstudio:projects:read";
13
+ "ardentstudio:projects:write": "ardentstudio:projects:write";
14
+ "ardentstudio:templates:read": "ardentstudio:templates:read";
15
+ "ardentstudio:templates:write": "ardentstudio:templates:write";
16
+ "ardentstudio:review:write": "ardentstudio:review:write";
17
+ "ardentstudio:agents:admin": "ardentstudio:agents:admin";
18
+ }>>>;
19
+ }, z.core.$strict>;
20
+ export type IdentityLink = z.infer<typeof linkSchema>;
21
+ /** Parse ARDENT_STUDIO_OATHWEAVE_IDENTITIES_JSON. Throws on anything malformed or ambiguous. */
22
+ export declare function parseIdentityLinks(raw: string): IdentityLink[];
23
+ /** The one link for a verified person, or undefined when there is none or the links are ambiguous. */
24
+ export declare function linkFor(links: readonly IdentityLink[], issuer: string, subject: string): IdentityLink | undefined;
25
+ /** Token permissions narrowed by the link's ceiling. Unknown permission strings are dropped. */
26
+ export declare function effectivePermissions(link: IdentityLink, tokenPermissions: readonly string[]): ArdentStudioPermission[];
27
+ export {};
@@ -0,0 +1,46 @@
1
+ import { z } from 'zod';
2
+ import { ARDENT_STUDIO_PERMISSIONS, permissionSchema } from '../shared/browser.js';
3
+ export class IdentityLinkError extends Error {
4
+ name = 'IdentityLinkError';
5
+ }
6
+ const linkSchema = z.object({
7
+ issuer: z.url(),
8
+ subject: z.string().min(1).max(256),
9
+ /** The Studio user this person acts as; recorded on every record and audit row. */
10
+ localUserId: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._@:-]{0,127}$/)
11
+ .refine(value => value !== 'system' && !value.startsWith('service:'), 'localUserId must not name the system or a service actor'),
12
+ displayName: z.string().trim().min(1).max(120).optional(),
13
+ /** A ceiling: the token's permissions grant only within it. Absent means every Studio permission. */
14
+ permissions: z.array(permissionSchema).max(ARDENT_STUDIO_PERMISSIONS.length).optional(),
15
+ }).strict();
16
+ /** Parse ARDENT_STUDIO_OATHWEAVE_IDENTITIES_JSON. Throws on anything malformed or ambiguous. */
17
+ export function parseIdentityLinks(raw) {
18
+ let json;
19
+ try {
20
+ json = JSON.parse(raw);
21
+ }
22
+ catch {
23
+ throw new IdentityLinkError('must be valid JSON');
24
+ }
25
+ const parsed = z.array(linkSchema).max(10_000).safeParse(json);
26
+ if (!parsed.success)
27
+ throw new IdentityLinkError('must be a JSON array of {issuer, subject, localUserId, displayName?, permissions?}');
28
+ const seen = new Set();
29
+ for (const link of parsed.data) {
30
+ const key = JSON.stringify([link.issuer, link.subject]);
31
+ if (seen.has(key))
32
+ throw new IdentityLinkError('links must be unique per issuer and subject');
33
+ seen.add(key);
34
+ }
35
+ return parsed.data;
36
+ }
37
+ /** The one link for a verified person, or undefined when there is none or the links are ambiguous. */
38
+ export function linkFor(links, issuer, subject) {
39
+ const matches = links.filter(link => link.issuer === issuer && link.subject === subject);
40
+ return matches.length === 1 ? matches[0] : undefined;
41
+ }
42
+ /** Token permissions narrowed by the link's ceiling. Unknown permission strings are dropped. */
43
+ export function effectivePermissions(link, tokenPermissions) {
44
+ const ceiling = new Set(link.permissions ?? ARDENT_STUDIO_PERMISSIONS);
45
+ return ARDENT_STUDIO_PERMISSIONS.filter(permission => ceiling.has(permission) && tokenPermissions.includes(permission));
46
+ }
@@ -0,0 +1,46 @@
1
+ import { type JWTVerifyGetKey } from 'jose';
2
+ import { type ArdentStudioPermission } from '../shared/browser.js';
3
+ import { type IdentityLink } from './links.js';
4
+ export interface OathweaveOptions {
5
+ issuer: string;
6
+ jwksUrl: string;
7
+ userinfoUrl: string;
8
+ audience?: string;
9
+ }
10
+ export interface OathweaveIdentity {
11
+ subject: string;
12
+ issuer: string;
13
+ sessionId: string;
14
+ permissions: string[];
15
+ displayName: string;
16
+ issuedAt: number;
17
+ expiresAt: number;
18
+ }
19
+ export type OathweaveVerifier = (token: string) => Promise<OathweaveIdentity>;
20
+ /** A verified, linked caller. */
21
+ export interface Principal {
22
+ userId: string;
23
+ subject: string;
24
+ issuer: string;
25
+ displayName: string;
26
+ permissions: ArdentStudioPermission[];
27
+ /** Token expiry in epoch seconds. Streams close at this time. */
28
+ expiresAt: number;
29
+ }
30
+ /** The token is valid but its subject has no explicit link, so the person has no Studio access. */
31
+ export declare class AccessDenied extends Error {
32
+ name: string;
33
+ }
34
+ /** How long a userinfo answer is reused, measured from when the request started. */
35
+ export declare const SESSION_CACHE_TTL_MS = 10000;
36
+ /** How often an open stream re-checks its token. */
37
+ export declare const STREAM_RECHECK_MS = 5000;
38
+ export declare function isHttpsEndpoint(value: string): boolean;
39
+ export declare function createOathweaveVerifier(options: OathweaveOptions, overrides?: {
40
+ keys?: JWTVerifyGetKey;
41
+ fetch?: typeof fetch;
42
+ now?: () => number;
43
+ }): OathweaveVerifier;
44
+ export type Authenticator = (token: string) => Promise<Principal>;
45
+ /** Verify a bearer token and map it through its one explicit identity link. */
46
+ export declare function createAuthenticator(verify: OathweaveVerifier, links: readonly IdentityLink[]): Authenticator;
@@ -0,0 +1,123 @@
1
+ // Oathweave resource-token verification for ArdentStudio, following the suite pattern
2
+ // (Magnus and HiveMem): RS256 only, exact issuer and audience, at most fifteen minutes,
3
+ // USER actor with a session, and an online session check through userinfo. Failure is closed.
4
+ import { createHash } from 'node:crypto';
5
+ import { createRemoteJWKSet, customFetch, jwtVerify } from 'jose';
6
+ import { ARDENT_STUDIO_AUDIENCE } from '../shared/browser.js';
7
+ import { effectivePermissions, linkFor } from './links.js';
8
+ /** The token is valid but its subject has no explicit link, so the person has no Studio access. */
9
+ export class AccessDenied extends Error {
10
+ name = 'AccessDenied';
11
+ }
12
+ /** How long a userinfo answer is reused, measured from when the request started. */
13
+ export const SESSION_CACHE_TTL_MS = 10_000;
14
+ /** How often an open stream re-checks its token. */
15
+ export const STREAM_RECHECK_MS = 5_000;
16
+ const FAILED_CHECK_TTL_MS = 2_000;
17
+ const SESSION_CACHE_MAX = 5_000;
18
+ const REQUEST_TIMEOUT_MS = 3_000;
19
+ export function isHttpsEndpoint(value) {
20
+ try {
21
+ const url = new URL(value);
22
+ return url.protocol === 'https:' && !url.username && !url.password && !url.hash;
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ export function createOathweaveVerifier(options, overrides = {}) {
29
+ for (const value of [options.issuer, options.jwksUrl, options.userinfoUrl]) {
30
+ if (!isHttpsEndpoint(value))
31
+ throw new Error('Oathweave endpoints require verified HTTPS');
32
+ }
33
+ const audience = options.audience ?? ARDENT_STUDIO_AUDIENCE;
34
+ const request = overrides.fetch ?? ((input, init) => fetch(input, init));
35
+ const now = overrides.now ?? (() => Date.now());
36
+ const keys = overrides.keys ?? createRemoteJWKSet(new URL(options.jwksUrl), {
37
+ [customFetch]: (url, init) => request(url, { ...init, redirect: 'error', signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }),
38
+ });
39
+ // Holds only tokens whose signature verified, so garbage can neither fill it nor reach userinfo.
40
+ const sessions = new Map();
41
+ async function fetchDisplayName(token, subject) {
42
+ const result = await request(options.userinfoUrl, {
43
+ headers: { Authorization: `Bearer ${token}` },
44
+ redirect: 'error',
45
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
46
+ });
47
+ if (!result.ok)
48
+ throw new Error('Identity session is inactive');
49
+ const user = await result.json();
50
+ if (!user || typeof user !== 'object' || user.sub !== subject)
51
+ throw new Error('Identity subject mismatch');
52
+ return 'name' in user && typeof user.name === 'string' && user.name ? user.name : subject;
53
+ }
54
+ function sessionCheck(token, subject) {
55
+ const key = createHash('sha256').update(token).digest('hex');
56
+ const startedAt = now();
57
+ const current = sessions.get(key);
58
+ if (current && startedAt < current.expiresAt)
59
+ return current.displayName;
60
+ sessions.delete(key);
61
+ if (sessions.size >= SESSION_CACHE_MAX) {
62
+ for (const [entryKey, entry] of sessions)
63
+ if (entry.expiresAt <= startedAt)
64
+ sessions.delete(entryKey);
65
+ if (sessions.size >= SESSION_CACHE_MAX)
66
+ sessions.delete(sessions.keys().next().value);
67
+ }
68
+ const check = { expiresAt: startedAt + SESSION_CACHE_TTL_MS, displayName: fetchDisplayName(token, subject) };
69
+ check.displayName.catch(() => { check.expiresAt = Math.min(check.expiresAt, now() + FAILED_CHECK_TTL_MS); });
70
+ sessions.set(key, check);
71
+ return check.displayName;
72
+ }
73
+ return async (token) => {
74
+ const { payload } = await jwtVerify(token, keys, {
75
+ algorithms: ['RS256'],
76
+ issuer: options.issuer,
77
+ audience,
78
+ requiredClaims: ['sub', 'iat', 'exp', 'jti'],
79
+ maxTokenAge: '15m',
80
+ currentDate: new Date(now()),
81
+ });
82
+ if (payload.aud !== audience || typeof payload.iat !== 'number' || typeof payload.exp !== 'number'
83
+ || payload.exp <= payload.iat || payload.exp - payload.iat > 900) {
84
+ throw new Error('Invalid resource token audience or lifetime');
85
+ }
86
+ const claims = payload;
87
+ if (claims['actorType'] !== 'USER'
88
+ || typeof payload.sub !== 'string' || !payload.sub
89
+ || typeof payload.jti !== 'string' || !payload.jti
90
+ || typeof claims['sessionId'] !== 'string' || !claims['sessionId']
91
+ || typeof claims['clientId'] !== 'string'
92
+ || !Array.isArray(claims['permissions']) || !claims['permissions'].every(value => typeof value === 'string')) {
93
+ throw new Error('Invalid resource identity');
94
+ }
95
+ const displayName = await sessionCheck(token, payload.sub);
96
+ return {
97
+ subject: payload.sub,
98
+ issuer: options.issuer,
99
+ sessionId: claims['sessionId'],
100
+ permissions: claims['permissions'],
101
+ displayName,
102
+ issuedAt: payload.iat,
103
+ expiresAt: payload.exp,
104
+ };
105
+ };
106
+ }
107
+ /** Verify a bearer token and map it through its one explicit identity link. */
108
+ export function createAuthenticator(verify, links) {
109
+ return async (token) => {
110
+ const identity = await verify(token);
111
+ const link = linkFor(links, identity.issuer, identity.subject);
112
+ if (!link)
113
+ throw new AccessDenied('An explicit unique account link is required');
114
+ return {
115
+ userId: link.localUserId,
116
+ subject: identity.subject,
117
+ issuer: identity.issuer,
118
+ displayName: link.displayName ?? identity.displayName,
119
+ permissions: effectivePermissions(link, identity.permissions),
120
+ expiresAt: identity.expiresAt,
121
+ };
122
+ };
123
+ }
@@ -0,0 +1,45 @@
1
+ import type { Readable } from 'node:stream';
2
+ import type { Db } from '../db/database.js';
3
+ import type { Clock } from '../db/json.js';
4
+ export declare class BlobTooLargeError extends Error {
5
+ readonly limit: number;
6
+ name: string;
7
+ constructor(limit: number);
8
+ }
9
+ export declare class BlobIntegrityError extends Error {
10
+ name: string;
11
+ }
12
+ export interface BlobRecord {
13
+ sha256: string;
14
+ size: number;
15
+ mime: string;
16
+ path: string;
17
+ created_at: string;
18
+ }
19
+ export declare class BlobStore {
20
+ private readonly db;
21
+ readonly maxBytes: number;
22
+ private readonly clock;
23
+ readonly root: string;
24
+ private readonly tmp;
25
+ constructor(db: Db, dataDir: string, maxBytes: number, clock: Clock);
26
+ /** Remove temp files a crash left behind. Recorded blobs are never touched. */
27
+ recoverPartialWrites(): number;
28
+ relativePath(sha256: string): string;
29
+ get(sha256: string): BlobRecord | undefined;
30
+ putBytes(bytes: Uint8Array, mime: string, expectedSha256?: string): Promise<BlobRecord>;
31
+ /** Stream bytes into the store. Throws BlobTooLargeError past the cap and BlobIntegrityError on a digest mismatch. */
32
+ put(source: AsyncIterable<Uint8Array> | Readable, mime: string, expectedSha256?: string): Promise<BlobRecord>;
33
+ /** A read stream for a recorded blob, or undefined when it is not recorded. */
34
+ open(sha256: string): {
35
+ record: BlobRecord;
36
+ stream: Readable;
37
+ } | undefined;
38
+ readBytes(sha256: string): Promise<Uint8Array>;
39
+ /** A small recorded blob as UTF-8 text, verified against its address. */
40
+ readTextSync(sha256: string): string;
41
+ /** Re-hash a recorded blob. */
42
+ verify(sha256: string): Promise<boolean>;
43
+ private fileMatches;
44
+ private syncDirectory;
45
+ }
@@ -0,0 +1,159 @@
1
+ // Content-addressed blob store under <data>/blobs/aa/bb/<sha256>. Writes go to a temp file,
2
+ // are fsynced and renamed into place, and the stored file is hashed again before it is
3
+ // recorded, so a recorded blob always matches its address.
4
+ import { createHash, randomBytes } from 'node:crypto';
5
+ import { createReadStream, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
6
+ import { open, rename, rm, stat, mkdir } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ export class BlobTooLargeError extends Error {
9
+ limit;
10
+ name = 'BlobTooLargeError';
11
+ constructor(limit) {
12
+ super(`Blob exceeds the ${limit}-byte limit`);
13
+ this.limit = limit;
14
+ }
15
+ }
16
+ export class BlobIntegrityError extends Error {
17
+ name = 'BlobIntegrityError';
18
+ }
19
+ const SHA = /^[a-f0-9]{64}$/;
20
+ export class BlobStore {
21
+ db;
22
+ maxBytes;
23
+ clock;
24
+ root;
25
+ tmp;
26
+ constructor(db, dataDir, maxBytes, clock) {
27
+ this.db = db;
28
+ this.maxBytes = maxBytes;
29
+ this.clock = clock;
30
+ this.root = join(dataDir, 'blobs');
31
+ this.tmp = join(this.root, 'tmp');
32
+ mkdirSync(this.tmp, { recursive: true, mode: 0o700 });
33
+ }
34
+ /** Remove temp files a crash left behind. Recorded blobs are never touched. */
35
+ recoverPartialWrites() {
36
+ let removed = 0;
37
+ for (const name of readdirSync(this.tmp)) {
38
+ rmSync(join(this.tmp, name), { force: true });
39
+ removed++;
40
+ }
41
+ return removed;
42
+ }
43
+ relativePath(sha256) {
44
+ return join(sha256.slice(0, 2), sha256.slice(2, 4), sha256);
45
+ }
46
+ get(sha256) {
47
+ if (!SHA.test(sha256))
48
+ return undefined;
49
+ return this.db.prepare('SELECT * FROM blobs WHERE sha256 = ?').get(sha256);
50
+ }
51
+ async putBytes(bytes, mime, expectedSha256) {
52
+ return this.put((async function* () { yield bytes; })(), mime, expectedSha256);
53
+ }
54
+ /** Stream bytes into the store. Throws BlobTooLargeError past the cap and BlobIntegrityError on a digest mismatch. */
55
+ async put(source, mime, expectedSha256) {
56
+ if (expectedSha256 !== undefined && !SHA.test(expectedSha256))
57
+ throw new BlobIntegrityError('Expected digest is not a SHA-256 hex string');
58
+ const temp = join(this.tmp, `${randomBytes(12).toString('hex')}.part`);
59
+ const hash = createHash('sha256');
60
+ let size = 0;
61
+ const handle = await open(temp, 'wx', 0o600);
62
+ try {
63
+ try {
64
+ for await (const chunk of source) {
65
+ size += chunk.byteLength;
66
+ if (size > this.maxBytes)
67
+ throw new BlobTooLargeError(this.maxBytes);
68
+ hash.update(chunk);
69
+ await handle.write(chunk);
70
+ }
71
+ await handle.sync();
72
+ }
73
+ finally {
74
+ await handle.close();
75
+ }
76
+ const sha256 = hash.digest('hex');
77
+ if (expectedSha256 !== undefined && sha256 !== expectedSha256)
78
+ throw new BlobIntegrityError('Content does not match the expected SHA-256');
79
+ const relative = this.relativePath(sha256);
80
+ const target = join(this.root, relative);
81
+ const existing = this.get(sha256);
82
+ if (existing && await this.fileMatches(target, sha256, size)) {
83
+ await rm(temp, { force: true });
84
+ return existing;
85
+ }
86
+ await mkdir(join(this.root, sha256.slice(0, 2), sha256.slice(2, 4)), { recursive: true, mode: 0o700 });
87
+ await rename(temp, target);
88
+ await this.syncDirectory(join(this.root, sha256.slice(0, 2), sha256.slice(2, 4)));
89
+ if (!await this.fileMatches(target, sha256, size)) {
90
+ await rm(target, { force: true });
91
+ throw new BlobIntegrityError('Stored blob failed verification');
92
+ }
93
+ const record = { sha256, size, mime, path: relative, created_at: this.clock.now().toISOString() };
94
+ this.db.prepare('INSERT INTO blobs (sha256, size, mime, path, created_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (sha256) DO NOTHING')
95
+ .run(record.sha256, record.size, record.mime, record.path, record.created_at);
96
+ return this.get(sha256) ?? record;
97
+ }
98
+ catch (error) {
99
+ await rm(temp, { force: true });
100
+ throw error;
101
+ }
102
+ }
103
+ /** A read stream for a recorded blob, or undefined when it is not recorded. */
104
+ open(sha256) {
105
+ const record = this.get(sha256);
106
+ if (!record)
107
+ return undefined;
108
+ return { record, stream: createReadStream(join(this.root, record.path)) };
109
+ }
110
+ async readBytes(sha256) {
111
+ const opened = this.open(sha256);
112
+ if (!opened)
113
+ throw new BlobIntegrityError('Blob is not recorded');
114
+ const chunks = [];
115
+ for await (const chunk of opened.stream)
116
+ chunks.push(chunk);
117
+ const bytes = Buffer.concat(chunks);
118
+ if (createHash('sha256').update(bytes).digest('hex') !== sha256)
119
+ throw new BlobIntegrityError('Stored blob does not match its address');
120
+ return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
121
+ }
122
+ /** A small recorded blob as UTF-8 text, verified against its address. */
123
+ readTextSync(sha256) {
124
+ const record = this.get(sha256);
125
+ if (!record)
126
+ throw new BlobIntegrityError('Blob is not recorded');
127
+ const bytes = readFileSync(join(this.root, record.path));
128
+ if (createHash('sha256').update(bytes).digest('hex') !== sha256)
129
+ throw new BlobIntegrityError('Stored blob does not match its address');
130
+ return new TextDecoder('utf-8', { fatal: false }).decode(bytes);
131
+ }
132
+ /** Re-hash a recorded blob. */
133
+ async verify(sha256) {
134
+ const record = this.get(sha256);
135
+ return record ? this.fileMatches(join(this.root, record.path), sha256, record.size) : false;
136
+ }
137
+ async fileMatches(path, sha256, size) {
138
+ try {
139
+ if ((await stat(path)).size !== size)
140
+ return false;
141
+ const hash = createHash('sha256');
142
+ for await (const chunk of createReadStream(path))
143
+ hash.update(chunk);
144
+ return hash.digest('hex') === sha256;
145
+ }
146
+ catch {
147
+ return false;
148
+ }
149
+ }
150
+ async syncDirectory(path) {
151
+ const handle = await open(path, 'r');
152
+ try {
153
+ await handle.sync();
154
+ }
155
+ finally {
156
+ await handle.close();
157
+ }
158
+ }
159
+ }
@@ -0,0 +1,16 @@
1
+ export declare class ChatterMostError extends Error {
2
+ name: string;
3
+ }
4
+ export interface Announcement {
5
+ /** Plain text or Markdown, kept short. */
6
+ text: string;
7
+ /** A link back to the Studio page this announcement is about. */
8
+ link?: string | undefined;
9
+ }
10
+ export declare const ANNOUNCEMENT_USERNAME = "Ardent Project Studio";
11
+ export declare class ChatterMostClient {
12
+ private readonly webhookUrl;
13
+ private readonly request;
14
+ constructor(webhookUrl: string, fetcher?: typeof fetch);
15
+ announce(announcement: Announcement): Promise<void>;
16
+ }
@@ -0,0 +1,36 @@
1
+ // Optional one-way announcements through a ChatterMost incoming webhook. Posts are labelled as
2
+ // coming from Ardent Project Studio and link back; nothing is ever read back from ChatterMost.
3
+ // The webhook URL contains its secret, so it is never logged or returned.
4
+ export class ChatterMostError extends Error {
5
+ name = 'ChatterMostError';
6
+ }
7
+ export const ANNOUNCEMENT_USERNAME = 'Ardent Project Studio';
8
+ export class ChatterMostClient {
9
+ webhookUrl;
10
+ request;
11
+ constructor(webhookUrl, fetcher) {
12
+ this.webhookUrl = webhookUrl;
13
+ this.request = fetcher ?? ((input, init) => fetch(input, init));
14
+ }
15
+ async announce(announcement) {
16
+ const text = `${announcement.text.trim().slice(0, 3_500)}${announcement.link ? `\n${announcement.link}` : ''}`;
17
+ if (!text.trim())
18
+ throw new ChatterMostError('An announcement needs text');
19
+ let response;
20
+ try {
21
+ response = await this.request(this.webhookUrl, {
22
+ method: 'POST',
23
+ headers: { 'content-type': 'application/json' },
24
+ body: JSON.stringify({ text, username: ANNOUNCEMENT_USERNAME }),
25
+ redirect: 'error',
26
+ signal: AbortSignal.timeout(10_000),
27
+ });
28
+ }
29
+ catch (error) {
30
+ throw new ChatterMostError(`ChatterMost webhook request failed (${error instanceof Error ? error.name : 'error'})`);
31
+ }
32
+ await response.body?.cancel();
33
+ if (!response.ok)
34
+ throw new ChatterMostError(`ChatterMost webhook answered HTTP ${response.status}`);
35
+ }
36
+ }