@hivemagnus/ardent-studio 0.2.1 → 0.4.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/README.md CHANGED
@@ -80,6 +80,7 @@ when a required setting is missing or unsafe.
80
80
  | `OATHWEAVE_ISSUER` | yes | | Oathweave browser issuer (HTTPS). |
81
81
  | `OATHWEAVE_JWKS_URL` | yes | | Oathweave JWKS (HTTPS). |
82
82
  | `OATHWEAVE_USERINFO_URL` | yes | | Oathweave userinfo (HTTPS), for session revocation. |
83
+ | `OATHWEAVE_NATIVE_ISSUERS` | no | | Comma-separated Oathweave native issuer ids (its `JWT_ISSUER`). Service tokens they sign are accepted on the audit export only. |
83
84
  | `ARDENT_STUDIO_OATHWEAVE_IDENTITIES_JSON` | yes | | Explicit account links; see [docs/OATHWEAVE.md](docs/OATHWEAVE.md). |
84
85
  | `ARDENT_STUDIO_OATHWEAVE_TOKEN_URL` | yes | | Oathweave token endpoint for `client_credentials`. |
85
86
  | `ARDENT_STUDIO_MAGNUS_URL` | yes | | Magnus control API origin. |
@@ -104,6 +105,12 @@ permission, except the health routes. Every mutation takes `x-idempotency-key`.
104
105
  [`src/shared/browser.ts`](src/shared/browser.ts) (also exported as
105
106
  `@hivemagnus/ardent-studio/browser`).
106
107
 
108
+ `GET /api/ardentstudio/v1/audit/export` serves the audit log as suite `HiveAuditRecord` v1
109
+ records for a collector such as Cindertrace. It needs the permission `ardentstudio:audit:read`,
110
+ which an Oathweave service (client_credentials) token for the audience `ardentstudio` may carry,
111
+ or a linked Studio admin (`ardentstudio:agents:admin`). See
112
+ [docs/OATHWEAVE.md](docs/OATHWEAVE.md#audit-export).
113
+
107
114
  ## Container image
108
115
 
109
116
  ```bash
@@ -0,0 +1,22 @@
1
+ import type { AuditEntry, AuditRepository } from '../db/repos/audit.js';
2
+ import { type HiveAuditRecord } from '../shared/browser.js';
3
+ export declare const AUDIT_EXPORT_DEFAULT_LIMIT = 100;
4
+ export declare const AUDIT_EXPORT_MAX_LIMIT = 200;
5
+ export declare const AUDIT_DETAILS_MAX_BYTES = 4096;
6
+ type Scalar = string | number | boolean | null;
7
+ export interface AuditExportPage {
8
+ data: HiveAuditRecord[];
9
+ nextCursor: string | null;
10
+ }
11
+ /** The numeric id after a cursor; 0 for the first page. Throws a 400 HiveError for a malformed cursor. */
12
+ export declare function cursorPosition(after: string | undefined): number;
13
+ /** Flat scalars only: arrays of scalars are joined, other arrays become a count, objects are dropped. */
14
+ export declare function flattenDetails(detail: Record<string, unknown>): Record<string, Scalar>;
15
+ export declare function toAuditRecord(entry: AuditEntry, taskRefs?: Map<string, string>): HiveAuditRecord;
16
+ /**
17
+ * One export page for `?after=&limit=`. Throws a 400 HiveError for a malformed query.
18
+ * `nextCursor` is the last record's cursor whenever the page has records, even when it is the
19
+ * last page, so a collector can store it and poll again; it is null only for an empty page.
20
+ */
21
+ export declare function exportAuditPage(audit: AuditRepository, query: URLSearchParams | Readonly<Record<string, string | undefined>>): AuditExportPage;
22
+ export {};
@@ -0,0 +1,115 @@
1
+ // The suite audit export: Studio's append-only audit table as HiveAuditRecord v1, oldest first,
2
+ // keyset-paged on the table's monotonic id. Details are flattened to scalars, redacted with the
3
+ // suite's log redaction and capped at 4 KB; they never hold secrets or file contents.
4
+ import { decodeCursor, encodeCursor, parseFeedQuery } from '@hivemagnus/hive-common/events';
5
+ import { HiveError } from '@hivemagnus/hive-common/errors';
6
+ import { redact } from '@hivemagnus/hive-common/log';
7
+ import { agentRefSchema } from '../shared/browser.js';
8
+ export const AUDIT_EXPORT_DEFAULT_LIMIT = 100;
9
+ export const AUDIT_EXPORT_MAX_LIMIT = 200;
10
+ export const AUDIT_DETAILS_MAX_BYTES = 4096;
11
+ const MAX_STRING = 512;
12
+ /** Detail keys that could carry content rather than metadata. None are recorded today; this keeps it so. */
13
+ const CONTENT_KEYS = new Set(['body', 'body_md', 'content', 'text', 'prompt', 'brief', 'soul', 'identity', 'instructions', 'answer', 'message']);
14
+ /** The numeric id after a cursor; 0 for the first page. Throws a 400 HiveError for a malformed cursor. */
15
+ export function cursorPosition(after) {
16
+ if (after === undefined || after === '')
17
+ return 0;
18
+ const position = decodeCursor(after);
19
+ const id = position.id;
20
+ if (Object.keys(position).length !== 1 || typeof id !== 'number' || !Number.isSafeInteger(id) || id < 0) {
21
+ throw new HiveError('invalid_cursor', 'The cursor is not valid', { status: 400 });
22
+ }
23
+ return id;
24
+ }
25
+ function scalar(value) {
26
+ if (value === null || typeof value === 'boolean')
27
+ return value;
28
+ if (typeof value === 'number')
29
+ return Number.isFinite(value) ? value : undefined;
30
+ if (typeof value === 'string')
31
+ return value.length > MAX_STRING ? `${value.slice(0, MAX_STRING)}…` : value;
32
+ return undefined;
33
+ }
34
+ /** Flat scalars only: arrays of scalars are joined, other arrays become a count, objects are dropped. */
35
+ export function flattenDetails(detail) {
36
+ const redacted = redact(detail);
37
+ const flat = {};
38
+ for (const [key, value] of Object.entries(redacted)) {
39
+ if (CONTENT_KEYS.has(key.toLowerCase()))
40
+ continue;
41
+ const direct = scalar(value);
42
+ if (direct !== undefined) {
43
+ flat[key] = direct;
44
+ continue;
45
+ }
46
+ if (Array.isArray(value)) {
47
+ const items = value.map(item => scalar(item));
48
+ if (items.every(item => item !== undefined && item !== null && typeof item !== 'boolean')) {
49
+ const joined = scalar(items.join(','));
50
+ if (joined !== undefined)
51
+ flat[key] = joined;
52
+ }
53
+ else
54
+ flat[`${key}_count`] = value.length;
55
+ }
56
+ }
57
+ // Keep the record bounded: drop the longest values until the details fit.
58
+ while (Buffer.byteLength(JSON.stringify(flat)) > AUDIT_DETAILS_MAX_BYTES) {
59
+ const longest = Object.entries(flat).sort(([, a], [, b]) => JSON.stringify(b).length - JSON.stringify(a).length)[0];
60
+ if (!longest)
61
+ break;
62
+ delete flat[longest[0]];
63
+ flat.truncated = true;
64
+ }
65
+ return flat;
66
+ }
67
+ function actorOf(entry) {
68
+ const userId = entry.actor_user_id;
69
+ if (userId === 'system')
70
+ return { type: 'system', id: 'system' };
71
+ if (userId.startsWith('service:')) {
72
+ return { type: 'service', id: entry.actor_subject ?? userId.slice('service:'.length), ...(entry.actor_issuer ? { issuer: entry.actor_issuer } : {}) };
73
+ }
74
+ return {
75
+ type: 'user',
76
+ id: entry.actor_subject ?? userId,
77
+ ...(entry.actor_issuer ? { issuer: entry.actor_issuer } : {}),
78
+ displayName: userId,
79
+ };
80
+ }
81
+ /** The agent a record is about: an agent target, a task that ran on an agent, or a detail agent_ref. */
82
+ function agentRefOf(entry, taskRefs) {
83
+ const candidates = [
84
+ entry.target_type === 'agent' ? entry.target_id : undefined,
85
+ entry.target_type === 'task' ? taskRefs.get(entry.target_id) : undefined,
86
+ typeof entry.detail.agent_ref === 'string' ? entry.detail.agent_ref : undefined,
87
+ ];
88
+ return candidates.find(candidate => candidate !== undefined && agentRefSchema.safeParse(candidate).success);
89
+ }
90
+ export function toAuditRecord(entry, taskRefs = new Map()) {
91
+ const agentRef = agentRefOf(entry, taskRefs);
92
+ return {
93
+ id: `ardentstudio:audit:${entry.id}`,
94
+ product: 'ardentstudio',
95
+ occurredAt: new Date(entry.at).toISOString(),
96
+ action: entry.action.toLowerCase(),
97
+ outcome: 'success',
98
+ actor: actorOf(entry),
99
+ ...(entry.target_type || entry.target_id ? { target: { ...(entry.target_type ? { type: entry.target_type } : {}), ...(entry.target_id ? { id: entry.target_id } : {}) } } : {}),
100
+ ...(agentRef ? { subject: { agentRef, nodeId: agentRef.slice(0, agentRef.indexOf('~')) } } : {}),
101
+ details: flattenDetails(entry.detail),
102
+ };
103
+ }
104
+ /**
105
+ * One export page for `?after=&limit=`. Throws a 400 HiveError for a malformed query.
106
+ * `nextCursor` is the last record's cursor whenever the page has records, even when it is the
107
+ * last page, so a collector can store it and poll again; it is null only for an empty page.
108
+ */
109
+ export function exportAuditPage(audit, query) {
110
+ const { after, limit } = parseFeedQuery(query, { defaultLimit: AUDIT_EXPORT_DEFAULT_LIMIT, maxLimit: AUDIT_EXPORT_MAX_LIMIT });
111
+ const entries = audit.listAfter(cursorPosition(after), limit);
112
+ const taskRefs = audit.taskAgentRefs(entries.filter(entry => entry.target_type === 'task').map(entry => entry.target_id));
113
+ const last = entries.at(-1);
114
+ return { data: entries.map(entry => toAuditRecord(entry, taskRefs)), nextCursor: last ? encodeCursor({ id: last.id }) : null };
115
+ }
@@ -0,0 +1,72 @@
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
+ * Oathweave native issuer identifiers (its JWT issuer name, for example `hive-iam`). Accepted
11
+ * for service tokens only, so only on routes that accept services (the audit export).
12
+ */
13
+ nativeIssuers?: readonly string[];
14
+ }
15
+ export interface OathweaveIdentity {
16
+ kind: 'user' | 'service';
17
+ subject: string;
18
+ issuer: string;
19
+ sessionId: string;
20
+ permissions: string[];
21
+ displayName: string;
22
+ issuedAt: number;
23
+ expiresAt: number;
24
+ }
25
+ export interface VerifyOptions {
26
+ /** `any` also accepts service (client-credentials) tokens. The default is people only. */
27
+ accept?: 'user' | 'any';
28
+ }
29
+ export type OathweaveVerifier = (token: string, options?: VerifyOptions) => Promise<OathweaveIdentity>;
30
+ /** The only permissions a service token can carry into Studio. */
31
+ export declare const SERVICE_PERMISSIONS: readonly ArdentStudioPermission[];
32
+ /** A verified, linked caller, or a verified service on a route that accepts services. */
33
+ export interface Principal {
34
+ /** Absent means a person, as before services were accepted anywhere. */
35
+ kind?: 'user' | 'service';
36
+ /** The linked Studio user, or `service:<client id>` for a service. */
37
+ userId: string;
38
+ subject: string;
39
+ issuer: string;
40
+ displayName: string;
41
+ permissions: ArdentStudioPermission[];
42
+ /** Token expiry in epoch seconds. Streams close at this time. */
43
+ expiresAt: number;
44
+ }
45
+ /** The token is valid but its subject has no explicit link, so the person has no Studio access. */
46
+ export declare class AccessDenied extends Error {
47
+ name: string;
48
+ }
49
+ /** How long a userinfo answer is reused, measured from when the request started. */
50
+ export declare const SESSION_CACHE_TTL_MS = 10000;
51
+ /** How often an open stream re-checks its token. */
52
+ export declare const STREAM_RECHECK_MS = 5000;
53
+ /**
54
+ * Studio has always checked token times exactly; the shared verifier would allow 30 s of skew.
55
+ * Kept at zero so this change does not widen what Studio accepts.
56
+ */
57
+ export declare const CLOCK_TOLERANCE_SEC = 0;
58
+ /**
59
+ * Build Studio's verifier on the shared one. Every refusal is an OathweaveError with a stable
60
+ * `reason`; the router maps all of them (including an unreachable JWKS or userinfo) to 401.
61
+ */
62
+ export declare function createOathweaveVerifier(options: OathweaveOptions, overrides?: {
63
+ keys?: JWTVerifyGetKey;
64
+ fetch?: typeof fetch;
65
+ now?: () => number;
66
+ }): OathweaveVerifier;
67
+ export type Authenticator = (token: string, options?: VerifyOptions) => Promise<Principal>;
68
+ /**
69
+ * Verify a bearer token and map a person through their one explicit identity link. A service
70
+ * (only with `accept: 'any'`) needs no link and keeps only SERVICE_PERMISSIONS.
71
+ */
72
+ export declare function createAuthenticator(verify: OathweaveVerifier, links: readonly IdentityLink[]): Authenticator;
@@ -0,0 +1,88 @@
1
+ // Oathweave resource-token verification for ArdentStudio, using the suite's shared verifier
2
+ // (@hivemagnus/hive-common/oathweave): RS256 only, exact issuer and audience, at most fifteen
3
+ // minutes, USER actor with a session, and an online session check through userinfo.
4
+ // Failure is closed. Studio accepts people only; service tokens are refused everywhere except on
5
+ // routes that opt in (the audit export), where a service gets only SERVICE_PERMISSIONS.
6
+ import { createOathweaveVerifier as createSharedVerifier, OATHWEAVE_SESSION_CACHE_TTL_MS, OATHWEAVE_STREAM_RECHECK_MS, } from '@hivemagnus/hive-common/oathweave';
7
+ import { ARDENT_STUDIO_AUDIENCE } from '../shared/browser.js';
8
+ import { effectivePermissions, linkFor } from './links.js';
9
+ /** The only permissions a service token can carry into Studio. */
10
+ export const SERVICE_PERMISSIONS = ['ardentstudio:audit:read'];
11
+ /** The token is valid but its subject has no explicit link, so the person has no Studio access. */
12
+ export class AccessDenied extends Error {
13
+ name = 'AccessDenied';
14
+ }
15
+ /** How long a userinfo answer is reused, measured from when the request started. */
16
+ export const SESSION_CACHE_TTL_MS = OATHWEAVE_SESSION_CACHE_TTL_MS;
17
+ /** How often an open stream re-checks its token. */
18
+ export const STREAM_RECHECK_MS = OATHWEAVE_STREAM_RECHECK_MS;
19
+ /**
20
+ * Studio has always checked token times exactly; the shared verifier would allow 30 s of skew.
21
+ * Kept at zero so this change does not widen what Studio accepts.
22
+ */
23
+ export const CLOCK_TOLERANCE_SEC = 0;
24
+ /**
25
+ * Build Studio's verifier on the shared one. Every refusal is an OathweaveError with a stable
26
+ * `reason`; the router maps all of them (including an unreachable JWKS or userinfo) to 401.
27
+ */
28
+ export function createOathweaveVerifier(options, overrides = {}) {
29
+ const verifier = createSharedVerifier({
30
+ issuer: options.issuer,
31
+ audience: options.audience ?? ARDENT_STUDIO_AUDIENCE,
32
+ jwksUrl: options.jwksUrl,
33
+ userinfoUrl: options.userinfoUrl,
34
+ ...(options.nativeIssuers?.length ? { nativeIssuers: options.nativeIssuers, nativeIssuerAccept: 'service' } : {}),
35
+ accept: 'user',
36
+ clockToleranceSec: CLOCK_TOLERANCE_SEC,
37
+ ...(overrides.keys ? { keys: overrides.keys } : {}),
38
+ ...(overrides.fetch ? { fetch: overrides.fetch } : {}),
39
+ ...(overrides.now ? { now: overrides.now } : {}),
40
+ });
41
+ return async (token, verifyOptions = {}) => {
42
+ const principal = verifyOptions.accept === 'any' ? await verifier.verify(token, { accept: 'any' }) : await verifier.verifyUser(token);
43
+ return {
44
+ kind: principal.kind,
45
+ subject: principal.subject,
46
+ issuer: principal.issuer,
47
+ sessionId: principal.kind === 'user' ? principal.sessionId : '',
48
+ permissions: [...principal.permissions],
49
+ displayName: principal.kind === 'user' ? principal.displayName : principal.subject,
50
+ issuedAt: principal.issuedAt,
51
+ expiresAt: principal.expiresAt,
52
+ };
53
+ };
54
+ }
55
+ /**
56
+ * Verify a bearer token and map a person through their one explicit identity link. A service
57
+ * (only with `accept: 'any'`) needs no link and keeps only SERVICE_PERMISSIONS.
58
+ */
59
+ export function createAuthenticator(verify, links) {
60
+ return async (token, options = {}) => {
61
+ const identity = await verify(token, options);
62
+ if (identity.kind === 'service') {
63
+ if (options.accept !== 'any')
64
+ throw new Error('Service tokens are not accepted here');
65
+ return {
66
+ kind: 'service',
67
+ userId: `service:${identity.subject}`,
68
+ subject: identity.subject,
69
+ issuer: identity.issuer,
70
+ displayName: identity.subject,
71
+ permissions: SERVICE_PERMISSIONS.filter(permission => identity.permissions.includes(permission)),
72
+ expiresAt: identity.expiresAt,
73
+ };
74
+ }
75
+ const link = linkFor(links, identity.issuer, identity.subject);
76
+ if (!link)
77
+ throw new AccessDenied('An explicit unique account link is required');
78
+ return {
79
+ kind: 'user',
80
+ userId: link.localUserId,
81
+ subject: identity.subject,
82
+ issuer: identity.issuer,
83
+ displayName: link.displayName ?? identity.displayName,
84
+ permissions: effectivePermissions(link, identity.permissions),
85
+ expiresAt: identity.expiresAt,
86
+ };
87
+ };
88
+ }
@@ -15,6 +15,7 @@ declare const linkSchema: z.ZodObject<{
15
15
  "ardentstudio:templates:write": "ardentstudio:templates:write";
16
16
  "ardentstudio:review:write": "ardentstudio:review:write";
17
17
  "ardentstudio:agents:admin": "ardentstudio:agents:admin";
18
+ "ardentstudio:audit:read": "ardentstudio:audit:read";
18
19
  }>>>;
19
20
  }, z.core.$strict>;
20
21
  export type IdentityLink = z.infer<typeof linkSchema>;
@@ -53,11 +53,12 @@ export interface HiveMemClientOptions {
53
53
  tokens: Pick<ServiceTokenProvider, 'token' | 'invalidate'>;
54
54
  fetch?: typeof fetch;
55
55
  timeoutMs?: number;
56
+ /** Extra headers per request; defaults to the current request context (x-request-id, traceparent). */
57
+ headers?: () => Record<string, string>;
56
58
  }
57
59
  export declare class HiveMemClient {
58
- private readonly options;
59
- private readonly request;
60
- private readonly baseUrl;
60
+ private readonly sdk;
61
+ private readonly probe;
61
62
  constructor(options: HiveMemClientOptions);
62
63
  /** Unauthenticated liveness of the HiveMem service. */
63
64
  health(): Promise<{
@@ -81,5 +82,4 @@ export declare class HiveMemClient {
81
82
  preview: KnowledgeSyncResponse;
82
83
  applied: KnowledgeSyncResponse;
83
84
  }>;
84
- private call;
85
85
  }
@@ -2,7 +2,10 @@
2
2
  // as a revision-aware source (POST /v1/knowledge/sources/sync). ArdentStudio calls it with its
3
3
  // own Oathweave service identity (audience `hivemem`), linked in HiveMem's
4
4
  // HIVEMEM_OATHWEAVE_SERVICES_JSON with the memories:write and workspaces:write scopes.
5
+ // The HTTP layer is @hivemagnus/hivemem-sdk; this module keeps Studio's own interface on top of it.
5
6
  import { createHash } from 'node:crypto';
7
+ import { outboundHeaders } from '@hivemagnus/hive-common/context';
8
+ import { HiveMem, HiveMemHTTPError } from '@hivemagnus/hivemem-sdk';
6
9
  import { z } from 'zod';
7
10
  import { trimTrailingSlashes } from '../url.js';
8
11
  export class HiveMemError extends Error {
@@ -32,32 +35,72 @@ export const HIVEMEM_MAX_SYNC_BYTES = 4 * 1024 * 1024;
32
35
  export function chunkHash(content) {
33
36
  return createHash('sha256').update(content).digest('hex');
34
37
  }
38
+ /** Studio's errors: the server's `detail` (or `error`), else the HTTP status. */
39
+ function translate(error) {
40
+ if (!(error instanceof HiveMemHTTPError))
41
+ return error;
42
+ const details = error.details;
43
+ const message = typeof details?.detail === 'string' ? details.detail : typeof details?.error === 'string' ? details.error : `HiveMem answered HTTP ${error.statusCode}`;
44
+ return new HiveMemError(message.slice(0, 300), error.statusCode);
45
+ }
46
+ async function sdkCall(call) {
47
+ try {
48
+ return await call();
49
+ }
50
+ catch (error) {
51
+ throw translate(error);
52
+ }
53
+ }
54
+ /**
55
+ * The SDK reads an error body as JSON and then as text, which fails on a non-JSON body (a proxy
56
+ * page, for example). Hand it an empty JSON body instead so the error still carries its status.
57
+ */
58
+ function jsonErrorBodies(request) {
59
+ return async (input, init) => {
60
+ const response = await request(input, init);
61
+ if (response.ok || response.headers.get('content-type')?.includes('json'))
62
+ return response;
63
+ await response.body?.cancel().catch(() => undefined);
64
+ return new Response('null', { status: response.status, statusText: response.statusText, headers: { 'content-type': 'application/json' } });
65
+ };
66
+ }
35
67
  export class HiveMemClient {
36
- options;
37
- request;
38
- baseUrl;
68
+ sdk;
69
+ probe;
39
70
  constructor(options) {
40
- this.options = options;
41
- this.request = options.fetch ?? ((input, init) => fetch(input, init));
42
- this.baseUrl = trimTrailingSlashes(options.baseUrl);
71
+ const request = options.fetch ?? ((input, init) => fetch(input, init));
72
+ const fetchImpl = jsonErrorBodies(request);
73
+ const headers = options.headers ?? (() => outboundHeaders());
74
+ this.sdk = new HiveMem({
75
+ baseUrl: trimTrailingSlashes(options.baseUrl),
76
+ fetchImpl,
77
+ headers,
78
+ // Studio keeps its own retry schedule for knowledge sync; the SDK only retries once after a 401.
79
+ maxRetries: 0,
80
+ timeoutMs: options.timeoutMs ?? 30_000,
81
+ tokenProvider: async ({ forceRefresh }) => {
82
+ if (forceRefresh)
83
+ options.tokens.invalidate();
84
+ return options.tokens.token();
85
+ },
86
+ });
87
+ // Health is unauthenticated: an empty token keeps the SDK from reading one from the environment.
88
+ this.probe = new HiveMem({ baseUrl: this.sdk.baseUrl, fetchImpl, headers, token: '', maxRetries: 0, timeoutMs: 5_000 });
43
89
  }
44
90
  /** Unauthenticated liveness of the HiveMem service. */
45
91
  async health() {
46
- const response = await this.request(`${this.baseUrl}/v1/health`, { redirect: 'error', signal: AbortSignal.timeout(5_000) });
47
- if (!response.ok)
48
- throw new HiveMemError(`HiveMem health answered HTTP ${response.status}`, response.status);
49
- return z.object({ status: z.string() }).loose().parse(await response.json());
92
+ return z.object({ status: z.string() }).loose().parse(await sdkCall(() => this.probe.health()));
50
93
  }
51
94
  /** Create the project workspace. HiveMem returns the existing workspace when it already exists. */
52
95
  async ensureWorkspace(input) {
53
- return workspaceSchema.parse(await this.call('POST', '/v1/workspaces', {
54
- workspace_id: input.workspace_id, title: input.title.slice(0, 200), description: input.description ?? null, metadata: input.metadata ?? {},
55
- }));
96
+ return workspaceSchema.parse(await sdkCall(() => this.sdk.createWorkspace(input.workspace_id, {
97
+ title: input.title.slice(0, 200), description: input.description ?? null, metadata: input.metadata ?? {},
98
+ })));
56
99
  }
57
100
  async syncKnowledgeSource(body) {
58
101
  if (body.chunks.length > HIVEMEM_MAX_CHUNKS)
59
102
  throw new HiveMemError(`A source sync holds at most ${HIVEMEM_MAX_CHUNKS} chunks`, 400);
60
- return knowledgeSyncResponseSchema.parse(await this.call('POST', '/v1/knowledge/sources/sync', body));
103
+ return knowledgeSyncResponseSchema.parse(await sdkCall(() => this.sdk.syncKnowledgeSource({ ...body })));
61
104
  }
62
105
  /** Validate, then write: a preview proves the request before anything changes. */
63
106
  async syncKnowledgeSourceChecked(body) {
@@ -65,25 +108,4 @@ export class HiveMemClient {
65
108
  const applied = await this.syncKnowledgeSource({ ...body, mode: 'apply' });
66
109
  return { preview, applied };
67
110
  }
68
- async call(method, path, body) {
69
- const attempt = async () => this.request(`${this.baseUrl}${path}`, {
70
- method,
71
- headers: { authorization: `Bearer ${await this.options.tokens.token()}`, accept: 'application/json', ...(body !== undefined ? { 'content-type': 'application/json' } : {}) },
72
- ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
73
- redirect: 'error',
74
- signal: AbortSignal.timeout(this.options.timeoutMs ?? 30_000),
75
- });
76
- let response = await attempt();
77
- if (response.status === 401) {
78
- await response.body?.cancel();
79
- this.options.tokens.invalidate();
80
- response = await attempt();
81
- }
82
- if (!response.ok) {
83
- const detail = await response.json().catch(() => null);
84
- const message = typeof detail?.detail === 'string' ? detail.detail : typeof detail?.error === 'string' ? detail.error : `HiveMem answered HTTP ${response.status}`;
85
- throw new HiveMemError(message.slice(0, 300), response.status);
86
- }
87
- return response.status === 204 ? null : response.json();
88
- }
89
111
  }
package/dist/config.d.ts CHANGED
@@ -22,6 +22,8 @@ export interface Config {
22
22
  userinfoUrl: string;
23
23
  tokenUrl: string;
24
24
  identities: IdentityLink[];
25
+ /** OATHWEAVE_NATIVE_ISSUERS: native issuer ids accepted for service tokens on the audit export. */
26
+ nativeIssuers: string[];
25
27
  };
26
28
  magnus: ServiceClientConfig;
27
29
  hivemem: ServiceClientConfig;
package/dist/config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { isAbsolute, resolve } from 'node:path';
2
2
  import { z } from 'zod';
3
+ import { OATHWEAVE_NATIVE_ISSUER_PATTERN } from '@hivemagnus/hive-common/oathweave';
3
4
  import { parseIdentityLinks } from './auth/links.js';
4
5
  import { trimTrailingSlashes } from './url.js';
5
6
  export const DEFAULT_PORT = 8097;
@@ -24,6 +25,7 @@ export const ENV_CATALOG = [
24
25
  { name: 'OATHWEAVE_ISSUER', required: true, secret: false, description: 'Oathweave browser issuer (HTTPS).' },
25
26
  { name: 'OATHWEAVE_JWKS_URL', required: true, secret: false, description: 'Oathweave JWKS URL (HTTPS).' },
26
27
  { name: 'OATHWEAVE_USERINFO_URL', required: true, secret: false, description: 'Oathweave userinfo URL (HTTPS), used for online session revocation.' },
28
+ { name: 'OATHWEAVE_NATIVE_ISSUERS', required: false, secret: false, description: 'Optional comma-separated Oathweave native issuer ids (its JWT issuer name). Service tokens they sign are accepted on the audit export only; never for people.' },
27
29
  { name: 'ARDENT_STUDIO_OATHWEAVE_IDENTITIES_JSON', required: true, secret: false, description: 'JSON array of explicit account links: [{issuer, subject, localUserId, displayName?, permissions?}].' },
28
30
  { name: 'ARDENT_STUDIO_OATHWEAVE_TOKEN_URL', required: true, secret: false, description: 'Oathweave OAuth token endpoint for the client_credentials grant.' },
29
31
  { name: 'ARDENT_STUDIO_MAGNUS_URL', required: true, secret: false, description: 'Magnus control API base URL (the origin that serves /api/v1).' },
@@ -105,6 +107,10 @@ export function loadConfig(env = process.env) {
105
107
  const jwksUrl = url('OATHWEAVE_JWKS_URL', true);
106
108
  const userinfoUrl = url('OATHWEAVE_USERINFO_URL', true);
107
109
  const tokenUrl = url('ARDENT_STUDIO_OATHWEAVE_TOKEN_URL', false);
110
+ const nativeIssuers = (value('OATHWEAVE_NATIVE_ISSUERS') ?? '').split(',').map(item => item.trim()).filter(Boolean);
111
+ if (nativeIssuers.some(item => !OATHWEAVE_NATIVE_ISSUER_PATTERN.test(item))) {
112
+ issues.push({ name: 'OATHWEAVE_NATIVE_ISSUERS', message: 'must be comma-separated Oathweave native issuer ids ([a-z][a-z0-9-], at most 64 each)' });
113
+ }
108
114
  let identities = [];
109
115
  const identitiesRaw = required('ARDENT_STUDIO_OATHWEAVE_IDENTITIES_JSON');
110
116
  if (identitiesRaw) {
@@ -152,7 +158,7 @@ export function loadConfig(env = process.env) {
152
158
  maxBlobBytes: numeric('ARDENT_STUDIO_MAX_BLOB_BYTES', DEFAULT_MAX_BLOB_BYTES, 1024, 1024 * 1024 * 1024),
153
159
  eventRetentionDays: numeric('ARDENT_STUDIO_EVENT_RETENTION_DAYS', DEFAULT_EVENT_RETENTION_DAYS, 1, 3650),
154
160
  logLevel: logLevel,
155
- oathweave: { issuer, jwksUrl, userinfoUrl, tokenUrl, identities },
161
+ oathweave: { issuer, jwksUrl, userinfoUrl, tokenUrl, identities, nativeIssuers },
156
162
  magnus,
157
163
  hivemem,
158
164
  chattermost,
@@ -34,4 +34,8 @@ export declare class AuditRepository {
34
34
  targetId?: string;
35
35
  limit?: number;
36
36
  }): AuditEntry[];
37
+ /** Oldest first, strictly after `afterId`. The id is the table's monotonic key, so pages never skip or repeat. */
38
+ listAfter(afterId: number, limit: number): AuditEntry[];
39
+ /** The Ardent agent each task ran on, for the tasks that have one. */
40
+ taskAgentRefs(taskIds: readonly string[]): Map<string, string>;
37
41
  }
@@ -17,4 +17,20 @@ export class AuditRepository {
17
17
  : this.db.prepare('SELECT * FROM audit ORDER BY id DESC LIMIT ?').all(filter.limit ?? 100));
18
18
  return rows.map(({ detail_json, ...row }) => ({ ...row, detail: readJson(detail_json, {}) }));
19
19
  }
20
+ /** Oldest first, strictly after `afterId`. The id is the table's monotonic key, so pages never skip or repeat. */
21
+ listAfter(afterId, limit) {
22
+ const rows = this.db.prepare('SELECT * FROM audit WHERE id > ? ORDER BY id ASC LIMIT ?').all(afterId, limit);
23
+ return rows.map(({ detail_json, ...row }) => ({ ...row, detail: readJson(detail_json, {}) }));
24
+ }
25
+ /** The Ardent agent each task ran on, for the tasks that have one. */
26
+ taskAgentRefs(taskIds) {
27
+ const refs = new Map();
28
+ if (!taskIds.length)
29
+ return refs;
30
+ const rows = this.db.prepare(`SELECT id, ardent_agent_ref FROM tasks WHERE ardent_agent_ref IS NOT NULL AND id IN (${taskIds.map(() => '?').join(', ')})`)
31
+ .all(...taskIds);
32
+ for (const row of rows)
33
+ refs.set(row.id, row.ardent_agent_ref);
34
+ return refs;
35
+ }
20
36
  }
@@ -43,6 +43,8 @@ export declare class TeamRepository {
43
43
  role_key: string;
44
44
  role_name: string;
45
45
  }>;
46
+ /** Ids of the projects where an agent is bound to a role in the published or the draft team. */
47
+ projectsForAgent(agentRef: string): Set<string>;
46
48
  ensureBinding(agentRef: string, displayName?: string | null): AgentBinding;
47
49
  binding(agentRef: string): AgentBinding | undefined;
48
50
  bindings(): AgentBinding[];
@@ -77,6 +77,13 @@ export class TeamRepository {
77
77
  .filter(role => role.agent_ref)
78
78
  .map(role => ({ agent_ref: role.agent_ref, project_id: row.project_id, project_name: row.project_name, role_key: role.key, role_name: role.name })));
79
79
  }
80
+ /** Ids of the projects where an agent is bound to a role in the published or the draft team. */
81
+ projectsForAgent(agentRef) {
82
+ const rows = this.db.prepare(`SELECT DISTINCT r.project_id FROM team_assignments a
83
+ JOIN team_assignment_revisions r ON r.project_id = a.project_id AND r.revision IN (a.current_revision, a.draft_revision)
84
+ WHERE EXISTS (SELECT 1 FROM json_each(r.body_json, '$.roles') role WHERE json_extract(role.value, '$.agent_ref') = ?)`).all(agentRef);
85
+ return new Set(rows.map(row => row.project_id));
86
+ }
80
87
  // ── Agent bindings: a stable Studio id per Magnus-managed agent ──
81
88
  ensureBinding(agentRef, displayName) {
82
89
  const now = this.clock.now().toISOString();
package/dist/index.d.ts CHANGED
@@ -10,7 +10,7 @@ export { MagnusClient, MagnusError, parseAgentRef, agentRef, STUDIO_ACTIONS, REL
10
10
  export { HiveMemClient, HiveMemError } from './clients/hivemem.js';
11
11
  export { ChatterMostClient } from './clients/chattermost.js';
12
12
  export { ServiceTokenProvider } from './clients/service-token.js';
13
- export { createOathweaveVerifier, createAuthenticator, AccessDenied, type Principal } from './auth/oathweave.js';
13
+ export { createOathweaveVerifier, createAuthenticator, AccessDenied, type Principal } from './auth/authenticator.js';
14
14
  export { parseIdentityLinks, type IdentityLink } from './auth/links.js';
15
15
  export { validateTeam, type ValidationInput, type ValidationResult } from './team/validation.js';
16
16
  export { TEAM_SEEDS, loadSeedTemplates } from './templates/seeds.js';
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ export { MagnusClient, MagnusError, parseAgentRef, agentRef, STUDIO_ACTIONS, REL
9
9
  export { HiveMemClient, HiveMemError } from './clients/hivemem.js';
10
10
  export { ChatterMostClient } from './clients/chattermost.js';
11
11
  export { ServiceTokenProvider } from './clients/service-token.js';
12
- export { createOathweaveVerifier, createAuthenticator, AccessDenied } from './auth/oathweave.js';
12
+ export { createOathweaveVerifier, createAuthenticator, AccessDenied } from './auth/authenticator.js';
13
13
  export { parseIdentityLinks } from './auth/links.js';
14
14
  export { validateTeam } from './team/validation.js';
15
15
  export { TEAM_SEEDS, loadSeedTemplates } from './templates/seeds.js';
package/dist/logger.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
2
2
  export type Logger = Record<LogLevel, (message: string, fields?: Record<string, unknown>) => void>;
3
- /** JSON lines on stderr. Callers never pass secrets, tokens or file contents. */
3
+ /** JSON lines on stderr, with the request ID inside a request. Callers never pass secrets, tokens or file contents. */
4
4
  export declare function createLogger(level?: LogLevel, write?: (line: string) => void): Logger;
5
5
  export declare const silentLogger: Logger;
package/dist/logger.js CHANGED
@@ -1,10 +1,12 @@
1
+ import { getRequestContext } from '@hivemagnus/hive-common/context';
1
2
  const ORDER = { debug: 10, info: 20, warn: 30, error: 40 };
2
- /** JSON lines on stderr. Callers never pass secrets, tokens or file contents. */
3
+ /** JSON lines on stderr, with the request ID inside a request. Callers never pass secrets, tokens or file contents. */
3
4
  export function createLogger(level = 'info', write = line => process.stderr.write(`${line}\n`)) {
4
5
  const log = (at) => (message, fields = {}) => {
5
6
  if (ORDER[at] < ORDER[level])
6
7
  return;
7
- write(JSON.stringify({ at: new Date().toISOString(), level: at, message, ...fields }));
8
+ const requestId = getRequestContext()?.requestId;
9
+ write(JSON.stringify({ at: new Date().toISOString(), level: at, message, ...(requestId ? { requestId } : {}), ...fields }));
8
10
  };
9
11
  return { debug: log('debug'), info: log('info'), warn: log('warn'), error: log('error') };
10
12
  }