@hivemagnus/ardent-studio 0.2.0 → 0.3.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
@@ -28,7 +28,7 @@ project event stream.
28
28
  | [Oathweave](https://github.com/ChefTaco/Oathweave) | Yes | Sign-in for people (audience `ardentstudio`) and ArdentStudio's own service identity. |
29
29
  | ChatterMost | No | Optional one-way announcements through an incoming webhook. |
30
30
 
31
- Node.js 24.22.1+ or 26.13.0+.
31
+ Node.js 24.22.3+ or 26.13.1+.
32
32
 
33
33
  ## Architecture
34
34
 
@@ -1,4 +1,4 @@
1
- import { type JWTVerifyGetKey } from 'jose';
1
+ import type { JWTVerifyGetKey } from 'jose';
2
2
  import { type ArdentStudioPermission } from '../shared/browser.js';
3
3
  import { type IdentityLink } from './links.js';
4
4
  export interface OathweaveOptions {
@@ -35,7 +35,15 @@ export declare class AccessDenied extends Error {
35
35
  export declare const SESSION_CACHE_TTL_MS = 10000;
36
36
  /** How often an open stream re-checks its token. */
37
37
  export declare const STREAM_RECHECK_MS = 5000;
38
- export declare function isHttpsEndpoint(value: string): boolean;
38
+ /**
39
+ * Studio has always checked token times exactly; the shared verifier would allow 30 s of skew.
40
+ * Kept at zero so this change does not widen what Studio accepts.
41
+ */
42
+ export declare const CLOCK_TOLERANCE_SEC = 0;
43
+ /**
44
+ * Build Studio's verifier on the shared one. Every refusal is an OathweaveError with a stable
45
+ * `reason`; the router maps all of them (including an unreachable JWKS or userinfo) to 401.
46
+ */
39
47
  export declare function createOathweaveVerifier(options: OathweaveOptions, overrides?: {
40
48
  keys?: JWTVerifyGetKey;
41
49
  fetch?: typeof fetch;
@@ -0,0 +1,66 @@
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.
5
+ import { createOathweaveVerifier as createSharedVerifier, OATHWEAVE_SESSION_CACHE_TTL_MS, OATHWEAVE_STREAM_RECHECK_MS, } from '@hivemagnus/hive-common/oathweave';
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 = OATHWEAVE_SESSION_CACHE_TTL_MS;
14
+ /** How often an open stream re-checks its token. */
15
+ export const STREAM_RECHECK_MS = OATHWEAVE_STREAM_RECHECK_MS;
16
+ /**
17
+ * Studio has always checked token times exactly; the shared verifier would allow 30 s of skew.
18
+ * Kept at zero so this change does not widen what Studio accepts.
19
+ */
20
+ export const CLOCK_TOLERANCE_SEC = 0;
21
+ /**
22
+ * Build Studio's verifier on the shared one. Every refusal is an OathweaveError with a stable
23
+ * `reason`; the router maps all of them (including an unreachable JWKS or userinfo) to 401.
24
+ */
25
+ export function createOathweaveVerifier(options, overrides = {}) {
26
+ const verifier = createSharedVerifier({
27
+ issuer: options.issuer,
28
+ audience: options.audience ?? ARDENT_STUDIO_AUDIENCE,
29
+ jwksUrl: options.jwksUrl,
30
+ userinfoUrl: options.userinfoUrl,
31
+ accept: 'user',
32
+ clockToleranceSec: CLOCK_TOLERANCE_SEC,
33
+ ...(overrides.keys ? { keys: overrides.keys } : {}),
34
+ ...(overrides.fetch ? { fetch: overrides.fetch } : {}),
35
+ ...(overrides.now ? { now: overrides.now } : {}),
36
+ });
37
+ return async (token) => {
38
+ const principal = await verifier.verifyUser(token);
39
+ return {
40
+ subject: principal.subject,
41
+ issuer: principal.issuer,
42
+ sessionId: principal.sessionId,
43
+ permissions: [...principal.permissions],
44
+ displayName: principal.displayName,
45
+ issuedAt: principal.issuedAt,
46
+ expiresAt: principal.expiresAt,
47
+ };
48
+ };
49
+ }
50
+ /** Verify a bearer token and map it through its one explicit identity link. */
51
+ export function createAuthenticator(verify, links) {
52
+ return async (token) => {
53
+ const identity = await verify(token);
54
+ const link = linkFor(links, identity.issuer, identity.subject);
55
+ if (!link)
56
+ throw new AccessDenied('An explicit unique account link is required');
57
+ return {
58
+ userId: link.localUserId,
59
+ subject: identity.subject,
60
+ issuer: identity.issuer,
61
+ displayName: link.displayName ?? identity.displayName,
62
+ permissions: effectivePermissions(link, identity.permissions),
63
+ expiresAt: identity.expiresAt,
64
+ };
65
+ };
66
+ }
@@ -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
  }
@@ -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
  }
@@ -1,3 +1,4 @@
1
+ import { requestContext } from '@hivemagnus/hive-common/hono';
1
2
  import { Hono } from 'hono';
2
3
  import { ARDENT_STUDIO_API_PREFIX } from '../shared/browser.js';
3
4
  import { errorResponse } from './errors.js';
@@ -17,6 +18,9 @@ import { workRoutes } from './routes/work.js';
17
18
  /** The ArdentStudio HTTP API under /api/ardentstudio/v1. */
18
19
  export function createApp(deps) {
19
20
  const app = new Hono();
21
+ // x-request-id (or x-hive-request-id / x-correlation-id) and W3C traceparent: read, generated
22
+ // when absent, echoed as x-request-id, and attached to log lines written during the request.
23
+ app.use('*', requestContext());
20
24
  app.use('*', async (c, next) => {
21
25
  await next();
22
26
  c.header('x-content-type-options', 'nosniff');
@@ -1,6 +1,6 @@
1
1
  import type { AgentDirectory } from '../agents/directory.js';
2
2
  import type { OperationRelay } from '../agents/operations.js';
3
- import type { Authenticator, Principal } from '../auth/oathweave.js';
3
+ import type { Authenticator, Principal } from '../auth/authenticator.js';
4
4
  import type { BlobStore } from '../blobs/store.js';
5
5
  import type { HiveMemClient } from '../clients/hivemem.js';
6
6
  import type { MagnusClient } from '../clients/magnus.js';
@@ -1,4 +1,4 @@
1
- import type { Principal } from '../auth/oathweave.js';
1
+ import type { Principal } from '../auth/authenticator.js';
2
2
  export interface AppEnv {
3
3
  Variables: {
4
4
  principal: Principal | undefined;
@@ -1,28 +1,52 @@
1
+ // Health runs on the suite's shared readiness helper (@hivemagnus/hive-common/health), but keeps
2
+ // Studio's published body shape: HiveXUI parses it through the browser contract.
3
+ import { readiness } from '@hivemagnus/hive-common/health';
1
4
  const CACHE_MS = 10_000;
2
- const failed = (error) => ({ status: 'unavailable', error: error instanceof Error ? error.message.slice(0, 200) : 'unavailable' });
5
+ /** Above the Magnus client's own 20 s timeout, so the clients' timeouts decide, as before. */
6
+ const CHECK_TIMEOUT_MS = 25_000;
7
+ const toCheck = (report, maxError = 200) => report?.status === 'up'
8
+ ? { status: 'ok', error: null }
9
+ : { status: 'unavailable', error: (report?.error ?? 'unavailable').slice(0, maxError) };
3
10
  export function healthRoutes(register, deps) {
4
11
  let cached;
12
+ // Liveness never touches dependencies. Its body stays Studio's published `{status:'ok'}`.
5
13
  register('GET', '/health/live', () => ({ body: { status: 'ok' } }));
6
14
  register('GET', '/health/ready', async (c) => {
7
15
  if (!cached || Date.now() - cached.at > CACHE_MS) {
8
- let database = { status: 'ok', error: null };
9
- try {
10
- deps.store.db.prepare('SELECT 1').get();
11
- }
12
- catch (error) {
13
- database = failed(error);
14
- }
15
- const [agents, hivemem] = await Promise.all([
16
- deps.agents.list().then(list => list.magnus.state === 'ok'
17
- ? { magnus: { status: 'ok', error: null }, available: list.available_count }
18
- : { magnus: { status: 'unavailable', error: list.magnus.error }, available: 0 }),
19
- deps.hivemem.health().then(() => ({ status: 'ok', error: null }), failed),
20
- ]);
21
- // Ardent Project Studio requires at least one reachable Ardent agent.
22
- const ready = database.status === 'ok' && agents.magnus.status === 'ok' && hivemem.status === 'ok' && agents.available > 0;
16
+ // One Magnus listing serves both the magnus and the agents checks.
17
+ const listing = deps.agents.list();
18
+ listing.catch(() => undefined);
19
+ const report = await readiness({
20
+ timeoutMs: CHECK_TIMEOUT_MS,
21
+ exposeErrors: true,
22
+ checks: [
23
+ { name: 'database', check: () => { deps.store.db.prepare('SELECT 1').get(); } },
24
+ { name: 'magnus', check: async () => {
25
+ const list = await listing;
26
+ if (list.magnus.state !== 'ok')
27
+ throw new Error(list.magnus.error ?? 'unavailable');
28
+ } },
29
+ { name: 'hivemem', check: async () => { await deps.hivemem.health(); } },
30
+ // Ardent Project Studio requires at least one reachable Ardent agent.
31
+ { name: 'agents', check: async () => {
32
+ const list = await listing;
33
+ const available = list.magnus.state === 'ok' ? list.available_count : 0;
34
+ return { ok: available > 0, details: { available } };
35
+ } },
36
+ ],
37
+ });
38
+ const { checks } = report.body;
39
+ const agents = checks['agents'];
40
+ const available = typeof agents?.details?.['available'] === 'number' ? agents.details['available'] : 0;
41
+ const ready = report.status === 200;
23
42
  cached = { at: Date.now(), ready, body: {
24
43
  status: ready ? 'ready' : 'not_ready',
25
- checks: { database, magnus: agents.magnus, hivemem, agents: { status: agents.available > 0 ? 'ok' : 'unavailable', available: agents.available } },
44
+ checks: {
45
+ database: toCheck(checks['database']),
46
+ magnus: toCheck(checks['magnus'], 300),
47
+ hivemem: toCheck(checks['hivemem']),
48
+ agents: { status: agents?.status === 'up' ? 'ok' : 'unavailable', available },
49
+ },
26
50
  } };
27
51
  }
28
52
  c.header('cache-control', 'no-store');
@@ -27,6 +27,11 @@ export function projectRoutes(register, deps) {
27
27
  const needing = store.summaries.needingReview();
28
28
  projects = projects.filter(project => needing.has(project.id) === (filter.needs_review === 'true'));
29
29
  }
30
+ if (filter.agentRef !== undefined) {
31
+ // Narrows the list the caller can already read; it never adds a project.
32
+ const bound = store.team.projectsForAgent(filter.agentRef);
33
+ projects = projects.filter(project => bound.has(project.id));
34
+ }
30
35
  return { body: { projects: projects.map(summary) } };
31
36
  });
32
37
  register('POST', '/projects', (_c, { body, principal, idempotencyKey }) => {
@@ -1,5 +1,5 @@
1
1
  import { streamSSE } from 'hono/streaming';
2
- import { STREAM_RECHECK_MS } from '../../auth/oathweave.js';
2
+ import { STREAM_RECHECK_MS } from '../../auth/authenticator.js';
3
3
  import { requireProject } from '../deps.js';
4
4
  import { bearerToken } from '../routing.js';
5
5
  export const HEARTBEAT_MS = 15_000;
@@ -1,5 +1,5 @@
1
1
  import type { Context, Hono } from 'hono';
2
- import { type Authenticator, type Principal } from '../auth/oathweave.js';
2
+ import { type Authenticator, type Principal } from '../auth/authenticator.js';
3
3
  import type { Store } from '../db/store.js';
4
4
  import { type ArdentStudioBrowserRoute } from '../shared/browser.js';
5
5
  import type { AppEnv } from './env.js';
@@ -1,7 +1,7 @@
1
1
  // Every route is registered from its entry in the browser contract, so the permissions,
2
2
  // idempotency rule, query and body schemas the contract publishes are the ones enforced.
3
3
  import { createHash } from 'node:crypto';
4
- import { AccessDenied } from '../auth/oathweave.js';
4
+ import { AccessDenied } from '../auth/authenticator.js';
5
5
  import { ARDENT_STUDIO_API_PREFIX, ardentStudioBrowserRoutes, IDEMPOTENCY_HEADER, idempotencyKeySchema } from '../shared/browser.js';
6
6
  import { ApiError } from './errors.js';
7
7
  export const JSON_BODY_LIMIT = 1024 * 1024;
package/dist/service.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type ServerType } from '@hono/node-server';
2
- import { type Authenticator } from './auth/oathweave.js';
2
+ import { type Authenticator } from './auth/authenticator.js';
3
3
  import { ChatterMostClient } from './clients/chattermost.js';
4
4
  import { HiveMemClient } from './clients/hivemem.js';
5
5
  import { MagnusClient } from './clients/magnus.js';
package/dist/service.js CHANGED
@@ -3,7 +3,7 @@ import { join } from 'node:path';
3
3
  import { serve } from '@hono/node-server';
4
4
  import { AgentDirectory } from './agents/directory.js';
5
5
  import { OperationRelay } from './agents/operations.js';
6
- import { createAuthenticator, createOathweaveVerifier } from './auth/oathweave.js';
6
+ import { createAuthenticator, createOathweaveVerifier } from './auth/authenticator.js';
7
7
  import { BlobStore } from './blobs/store.js';
8
8
  import { ChatterMostClient } from './clients/chattermost.js';
9
9
  import { HiveMemClient } from './clients/hivemem.js';
@@ -337,6 +337,7 @@ export declare const listProjectsQuerySchema: z.ZodObject<{
337
337
  true: "true";
338
338
  false: "false";
339
339
  }>>;
340
+ agentRef: z.ZodOptional<z.ZodString>;
340
341
  }, z.core.$strip>;
341
342
  export declare const listProjectsResponseSchema: z.ZodObject<{
342
343
  projects: z.ZodArray<z.ZodObject<{
@@ -149,6 +149,8 @@ export const listProjectsQuerySchema = z.object({
149
149
  q: z.string().trim().max(200).optional(),
150
150
  /** true: only projects with review_pending > 0. */
151
151
  needs_review: z.enum(['true', 'false']).optional(),
152
+ /** Only projects where this agent (<magnusNodeId>~<profile>) is bound to a role in the published or the draft team. */
153
+ agentRef: agentRefSchema.optional(),
152
154
  });
153
155
  export const listProjectsResponseSchema = z.object({ projects: z.array(projectSummarySchema) });
154
156
  export const projectResponseSchema = projectSchema;
@@ -1222,7 +1224,7 @@ export const ardentStudioBrowserRoutes = [
1222
1224
  { method: 'GET', path: '/health/live', response: liveResponseSchema, permissions: [], idempotent: false },
1223
1225
  { method: 'GET', path: '/health/ready', response: readyResponseSchema, permissions: [], idempotent: false, note: 'Answers 503 with the same body when not ready. Checks are cached for 10 seconds.' },
1224
1226
  // Projects
1225
- { method: 'GET', path: '/projects', query: listProjectsQuerySchema, response: listProjectsResponseSchema, permissions: READ, idempotent: false, note: 'q matches name, brief and tags. needs_review=true keeps projects with review_pending > 0.' },
1227
+ { method: 'GET', path: '/projects', query: listProjectsQuerySchema, response: listProjectsResponseSchema, permissions: READ, idempotent: false, note: 'q matches name, brief and tags. needs_review=true keeps projects with review_pending > 0. agentRef keeps projects where that agent is bound to a role in the published or the draft team; an agent with no roles gets an empty list. 400 VALIDATION_ERROR for a malformed agentRef.' },
1226
1228
  { method: 'POST', path: '/projects', requestBody: 'json', request: createProjectRequestSchema, response: projectResponseSchema, permissions: WRITE, idempotent: true, successStatus: 201, note: 'Creates the project HiveMem workspace; when HiveMem is unavailable hivemem.state stays pending and is retried.' },
1227
1229
  { method: 'GET', path: '/projects/{projectId}', response: projectResponseSchema, permissions: READ, idempotent: false },
1228
1230
  { method: 'PATCH', path: '/projects/{projectId}', requestBody: 'json', request: updateProjectRequestSchema, response: projectResponseSchema, permissions: WRITE, idempotent: true, note: 'An archived project is read-only (409) except last_area.' },
@@ -97,6 +97,15 @@
97
97
  "false"
98
98
  ]
99
99
  }
100
+ },
101
+ {
102
+ "in": "query",
103
+ "name": "agentRef",
104
+ "required": false,
105
+ "schema": {
106
+ "type": "string",
107
+ "pattern": "^[A-Za-z0-9._-]{1,128}~[A-Za-z0-9._-]{1,128}$"
108
+ }
100
109
  }
101
110
  ],
102
111
  "responses": {
@@ -130,7 +139,7 @@
130
139
  "ardentstudio:projects:read"
131
140
  ],
132
141
  "x-idempotent": false,
133
- "description": "q matches name, brief and tags. needs_review=true keeps projects with review_pending > 0.",
142
+ "description": "q matches name, brief and tags. needs_review=true keeps projects with review_pending > 0. agentRef keeps projects where that agent is bound to a role in the published or the draft team; an agent with no roles gets an empty list. 400 VALIDATION_ERROR for a malformed agentRef.",
134
143
  "security": [
135
144
  {
136
145
  "oathweave": []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hivemagnus/ardent-studio",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Ardent Project Studio service: projects, team assignment, conversation room and deliverable review for Ardent agents managed by Magnus",
5
5
  "keywords": [
6
6
  "ardent",
@@ -38,7 +38,7 @@
38
38
  }
39
39
  },
40
40
  "engines": {
41
- "node": "^24.22.1 || ^26.13.0"
41
+ "node": "^24.22.3 || ^26.13.1"
42
42
  },
43
43
  "publishConfig": {
44
44
  "access": "public"
@@ -67,15 +67,17 @@
67
67
  "verify": "npm run privacy:test && npm run scan:secrets && npm run check:private && npm run typecheck && npm run lint && npm test && npm run build && npm run check:contracts && npm run pack:check"
68
68
  },
69
69
  "dependencies": {
70
+ "@hivemagnus/hive-common": "^0.1.0",
71
+ "@hivemagnus/hivemem-sdk": "1.1.0",
70
72
  "@hivemagnus/magnus-contracts": "2.2.0",
71
73
  "@hono/node-server": "^2.1.5",
74
+ "@photostructure/sqlite": "3.3.1",
72
75
  "fflate": "^0.8.3",
73
- "hono": "^4.13.17",
76
+ "hono": "^4.13.18",
74
77
  "jose": "^6.2.15",
75
78
  "pdfjs-dist": "^6.5.438",
76
79
  "yaml": "2.9.1",
77
- "zod": "^4.6.7",
78
- "@photostructure/sqlite": "3.3.1"
80
+ "zod": "^4.6.7"
79
81
  },
80
82
  "devDependencies": {
81
83
  "@apidevtools/swagger-parser": "^13.1.0",
@@ -1,123 +0,0 @@
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
- }