create-ailk 0.12.1 → 0.14.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 +12 -4
- package/component-catalog.json +247 -33
- package/component-catalog.md +10 -6
- package/dist/apply-design.d.ts +21 -2
- package/dist/apply-design.js +120 -19
- package/dist/cli.js +1 -0
- package/dist/design-compile.d.ts +70 -2
- package/dist/design-compile.js +97 -1
- package/dist/design-home.d.ts +74 -0
- package/dist/design-home.js +256 -0
- package/dist/design-package.d.ts +39 -2
- package/dist/design-package.js +223 -9
- package/dist/design-presets.d.ts +17 -1
- package/dist/design-presets.js +17 -5
- package/dist/design-scaffold.d.ts +6 -25
- package/dist/design-scaffold.js +26 -74
- package/dist/index.d.ts +1 -0
- package/dist/lib/design-checks.d.ts +12 -1
- package/dist/lib/design-checks.js +38 -2
- package/dist/lib/module-license-gate.d.ts +9 -0
- package/dist/lib/module-license-gate.js +18 -0
- package/dist/module-architecture.js +27 -0
- package/dist/programmatic.js +70 -26
- package/dist/site-chrome.d.ts +38 -0
- package/dist/site-chrome.js +109 -2
- package/dist/site-fonts.d.ts +21 -0
- package/dist/site-fonts.js +54 -0
- package/dist/site-roster.d.ts +16 -0
- package/dist/site-roster.js +81 -0
- package/dist/starter-grid.js +15 -0
- package/dist/surfaces.js +12 -0
- package/dist/team-profiles.d.ts +29 -0
- package/dist/team-profiles.js +43 -0
- package/package.json +3 -3
- package/release-scope.json +1 -1
- package/site-templates/_base/content/en/TeamList/team.mdx +68 -0
- package/site-templates/_base/content/en/TeamMember/first-member.mdx +22 -0
- package/site-templates/_base/content/en/TeamMember/second-member.mdx +22 -0
- package/site-templates/_base/content/en/TeamMember/third-member.mdx +22 -0
- package/site-templates/designs/minimalism/DESIGN.md +164 -34
- package/site-templates/designs/minimalism/home/local-business-service.mdx +119 -0
- package/site-templates/designs/minimalism/home/organization-product.mdx +132 -0
- package/site-templates/designs/minimalism/home/organization-service.mdx +131 -0
- package/site-templates/designs/minimalism/home/person-service.mdx +101 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +20 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +22 -1
- package/site-templates/marketing-site-free/ailk-template.json +1 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -7
- package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -7
- package/site-templates/shapes/organization-service/ailk-template.json +1 -1
- package/templates/.env.example +23 -9
- package/templates/README.md +2 -0
- package/templates/apps/api/CLAUDE.md +24 -17
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +98 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +321 -0
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +117 -0
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +39 -14
- package/templates/apps/api/src/config/index.ts +35 -20
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +3 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +14 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +468 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +32 -2
- package/templates/apps/api/src/lib/agent-store.ts +521 -0
- package/templates/apps/api/src/lib/spoke-store.ts +13 -2
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +384 -1
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +61 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +176 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +76 -0
- package/templates/apps/api/src/middleware/auth.ts +219 -5
- package/templates/apps/api/src/middleware/default-tenant.ts +29 -8
- package/templates/apps/api/src/middleware/rate-limit.ts +35 -13
- package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +76 -15
- package/templates/apps/api/src/routes/leads/index.ts +14 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -6
- package/templates/apps/api/src/routes/project-listings/index.ts +14 -9
- package/templates/apps/api/src/routes/project-listings/me.ts +4 -3
- package/templates/apps/api/src/routes/project-listings/patch-copy.ts +7 -5
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +198 -0
- package/templates/apps/api/src/server.ts +27 -0
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +374 -57
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +25 -2
- package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +303 -17
- package/templates/apps/api/src/services/audit-log.ts +278 -47
- package/templates/apps/api/src/services/default-tenant.ts +55 -5
- package/templates/apps/api/src/services/leads-inbox.ts +12 -21
- package/templates/apps/api/src/services/project-listings.ts +1 -21
- package/templates/apps/api/src/services/public-site-resolver.ts +103 -13
- package/templates/apps/mcp/CLAUDE.md +44 -5
- package/templates/apps/mcp/__tests__/parity.test.ts +53 -1
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +364 -0
- package/templates/apps/mcp/package.json +1 -1
- package/templates/apps/mcp/src/http-transport.ts +107 -1
- package/templates/apps/mcp/src/workspace-server.ts +193 -0
- package/templates/apps/web/.env.example +64 -37
- package/templates/apps/web/CLAUDE.md +11 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +10 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/__tests__/theme-modes.test.ts +42 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +8 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +14 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +69 -0
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +64 -0
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +15 -3
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +17 -6
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +14 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +4 -6
- package/templates/apps/web/app/[locale]/layout.tsx +156 -53
- package/templates/apps/web/app/[locale]/page.tsx +37 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +4 -6
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +15 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +43 -6
- package/templates/apps/web/app/[locale]/services/page.tsx +14 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/team/[slug]/page.tsx +45 -0
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +155 -0
- package/templates/apps/web/app/[locale]/team/page.tsx +49 -0
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +14 -1
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/not-found.tsx +47 -0
- package/templates/apps/web/app/v1/[...path]/route.ts +16 -0
- package/templates/apps/web/lib/__tests__/profile-back-link.test.ts +95 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +515 -0
- package/templates/apps/web/lib/__tests__/site-theme-head.test.ts +70 -0
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +64 -1
- package/templates/apps/web/lib/profile-back-link.ts +51 -0
- package/templates/apps/web/lib/relay.ts +279 -0
- package/templates/apps/web/lib/site-chrome.ts +12 -0
- package/templates/apps/web/lib/site-fonts.ts +15 -0
- package/templates/apps/web/lib/site-logo.tsx +39 -1
- package/templates/apps/web/lib/site-theme-head.ts +33 -0
- package/templates/apps/web/lib/site-theme.ts +13 -1
- package/templates/apps/web/messages/en.json +7 -1
- package/templates/apps/web/middleware.ts +5 -4
- package/templates/apps/web/next.config.mjs +6 -0
- package/templates/database/CHANGELOG.md +22 -0
- package/templates/database/__tests__/agents.db.test.ts +235 -0
- package/templates/database/auth/schema.prisma +61 -0
- package/templates/database/migrations/20261005120000_agents/migration.sql +84 -0
- package/templates/database/package.json +1 -1
- package/versions.json +35 -35
- package/site-templates/designs/minimalism/sample/README.md +0 -48
- package/site-templates/designs/minimalism/sample/content/en/HomePage/home.mdx +0 -244
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-1.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-2.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-3.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-4.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-5.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-6.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/hero-backdrop.svg +0 -17
- package/site-templates/designs/minimalism/sample/public/logo-placeholder-1.svg +0 -6
- package/site-templates/designs/minimalism/sample/public/logo-placeholder-2.svg +0 -6
- package/site-templates/designs/minimalism/sample/public/logo-placeholder-3.svg +0 -6
- package/site-templates/designs/minimalism/sample/public/wordmark-placeholder.svg +0 -28
|
@@ -112,15 +112,21 @@ export interface Config {
|
|
|
112
112
|
// env-configured CAL_API_KEY path works exactly as before. Held decoded,
|
|
113
113
|
// never as the raw string, and never logged.
|
|
114
114
|
SCHEDULING_CREDENTIAL_KEY?: Buffer;
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
115
|
+
// What a hub does with a request to a spoke-aware public surface (meeting
|
|
116
|
+
// types, the three lead writes, booking, availability, event meta, the
|
|
117
|
+
// public content reads) that carries no `x-ailk-spoke-key` (#7532 D7,
|
|
118
|
+
// #7633 D2). `"auto"` (unset, the default) refuses it once the spoke
|
|
119
|
+
// registry holds a row, and keeps the keyless answer on a deployment with
|
|
120
|
+
// no spoke; `"on"` always refuses it; `"off"` never does — an emergency
|
|
121
|
+
// opt-out, warned at boot. A mode, never a boolean: every value is a truthy
|
|
122
|
+
// string, so no reader may test it by truthiness. Parsed by
|
|
123
|
+
// parseHubRequireSpokeKey() below; read by services/public-site-resolver.ts.
|
|
124
|
+
AILK_HUB_REQUIRE_SPOKE_KEY: HubSpokeKeyMode;
|
|
122
125
|
}
|
|
123
126
|
|
|
127
|
+
/** `AILK_HUB_REQUIRE_SPOKE_KEY`'s three states (#7633 D2). */
|
|
128
|
+
export type HubSpokeKeyMode = 'auto' | 'on' | 'off';
|
|
129
|
+
|
|
124
130
|
/** Why an AILK_TRUSTED_ORIGINS entry was refused (#7395 D2). */
|
|
125
131
|
export type TrustedOriginRefusal =
|
|
126
132
|
| 'wildcard'
|
|
@@ -248,14 +254,16 @@ export function resolveSchedulingCredentialKey(
|
|
|
248
254
|
}
|
|
249
255
|
|
|
250
256
|
/**
|
|
251
|
-
* `AILK_HUB_REQUIRE_SPOKE_KEY` (#7532 D7):
|
|
252
|
-
* `
|
|
253
|
-
* value, which `validateConfig()`
|
|
257
|
+
* `AILK_HUB_REQUIRE_SPOKE_KEY` (#7532 D7, #7633 D2): unset, empty or blank →
|
|
258
|
+
* `"auto"`; `true`/`1` → `"on"`; `false`/`0` → `"off"` — case-insensitive,
|
|
259
|
+
* after trimming. `null` for any other value, which `validateConfig()`
|
|
260
|
+
* refuses at boot.
|
|
254
261
|
*/
|
|
255
|
-
export function parseHubRequireSpokeKey(raw: string | undefined):
|
|
262
|
+
export function parseHubRequireSpokeKey(raw: string | undefined): HubSpokeKeyMode | null {
|
|
256
263
|
const value = (raw ?? '').trim().toLowerCase();
|
|
257
|
-
if (value === '
|
|
258
|
-
if (value === '' || value === '
|
|
264
|
+
if (value === '') return 'auto';
|
|
265
|
+
if (value === 'true' || value === '1') return 'on';
|
|
266
|
+
if (value === 'false' || value === '0') return 'off';
|
|
259
267
|
return null;
|
|
260
268
|
}
|
|
261
269
|
|
|
@@ -338,11 +346,11 @@ function validateConfig(): Config {
|
|
|
338
346
|
process.exit(1);
|
|
339
347
|
}
|
|
340
348
|
|
|
341
|
-
// #7532 D7 — the hub
|
|
342
|
-
// the
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
349
|
+
// #7532 D7, #7633 D2 — the hub's spoke-key mode. An unparseable value stops
|
|
350
|
+
// the API: a typo read as "off" would silently leave the hub open to
|
|
351
|
+
// header-less callers. The message names the variable, never its value.
|
|
352
|
+
// "on" with no DATABASE_URL stops it too: with no registry to look a key up
|
|
353
|
+
// in it would refuse every call. "auto" and "off" boot without a database.
|
|
346
354
|
const hubRequireSpokeKey = parseHubRequireSpokeKey(process.env.AILK_HUB_REQUIRE_SPOKE_KEY);
|
|
347
355
|
if (hubRequireSpokeKey === null) {
|
|
348
356
|
const message =
|
|
@@ -351,13 +359,20 @@ function validateConfig(): Config {
|
|
|
351
359
|
console.error(message);
|
|
352
360
|
process.exit(1);
|
|
353
361
|
}
|
|
354
|
-
if (hubRequireSpokeKey && !process.env.DATABASE_URL) {
|
|
362
|
+
if (hubRequireSpokeKey === 'on' && !process.env.DATABASE_URL) {
|
|
355
363
|
const message =
|
|
356
364
|
'❌ AILK_HUB_REQUIRE_SPOKE_KEY is on but DATABASE_URL is not set — the spoke registry lives in the database';
|
|
357
365
|
logger.error(message);
|
|
358
366
|
console.error(message);
|
|
359
367
|
process.exit(1);
|
|
360
368
|
}
|
|
369
|
+
// The emergency opt-out is never silent: it reopens keyless access on every
|
|
370
|
+
// spoke-aware surface of a hub (#7633 D2, D6).
|
|
371
|
+
if (hubRequireSpokeKey === 'off') {
|
|
372
|
+
logger.warn(
|
|
373
|
+
'AILK_HUB_REQUIRE_SPOKE_KEY=false — a public request with no spoke key is accepted even when spokes are registered',
|
|
374
|
+
);
|
|
375
|
+
}
|
|
361
376
|
|
|
362
377
|
return {
|
|
363
378
|
NODE_ENV: nodeEnv,
|
|
@@ -430,7 +445,7 @@ function validateConfig(): Config {
|
|
|
430
445
|
...(schedulingKey.kind === 'ok' && {
|
|
431
446
|
SCHEDULING_CREDENTIAL_KEY: schedulingKey.key,
|
|
432
447
|
}),
|
|
433
|
-
AILK_HUB_REQUIRE_SPOKE_KEY: hubRequireSpokeKey
|
|
448
|
+
AILK_HUB_REQUIRE_SPOKE_KEY: hubRequireSpokeKey,
|
|
434
449
|
};
|
|
435
450
|
}
|
|
436
451
|
|
|
@@ -8,6 +8,9 @@ const MockPrismaClient = jest.fn().mockImplementation((options) => ({
|
|
|
8
8
|
user: {},
|
|
9
9
|
apiToken: {},
|
|
10
10
|
userData: {},
|
|
11
|
+
// #7633 D8 — the public resolver's "is this a hub" read. An empty registry,
|
|
12
|
+
// so a suite that reaches this stub keeps today's keyless answer.
|
|
13
|
+
spoke: { findFirst: jest.fn(async () => null) },
|
|
11
14
|
}));
|
|
12
15
|
|
|
13
16
|
// #4663 D6 — flow-engine.ts imports the real `Prisma` runtime namespace (a
|
|
@@ -67,6 +67,8 @@ export const prisma = {
|
|
|
67
67
|
user: {
|
|
68
68
|
findUnique: jest.fn(),
|
|
69
69
|
findFirst: jest.fn(),
|
|
70
|
+
// #3591 — `lib/agent-store.ts`'s list names each agent's creator by id.
|
|
71
|
+
findMany: jest.fn(),
|
|
70
72
|
update: jest.fn(),
|
|
71
73
|
create: jest.fn(),
|
|
72
74
|
},
|
|
@@ -387,6 +389,18 @@ export const prisma = {
|
|
|
387
389
|
findUnique: jest.fn(),
|
|
388
390
|
createMany: jest.fn(),
|
|
389
391
|
},
|
|
392
|
+
// ── #3591 — agents (auth) ────────────────────────────────────────────────
|
|
393
|
+
// Every call lives in `lib/agent-store.ts`: `findUnique` by credential
|
|
394
|
+
// digest (the auth preHandler's one lookup) or by id; `update` — the
|
|
395
|
+
// best-effort `lastActiveAt` bump; `create`/`updateMany`/`findMany` — the
|
|
396
|
+
// management functions, each scoped by the caller's organization id.
|
|
397
|
+
agent: {
|
|
398
|
+
findUnique: jest.fn(),
|
|
399
|
+
findMany: jest.fn(),
|
|
400
|
+
create: jest.fn(),
|
|
401
|
+
update: jest.fn(),
|
|
402
|
+
updateMany: jest.fn(),
|
|
403
|
+
},
|
|
390
404
|
// ── Content namespace (UsageEvent, UsagePeriod — FEAT-047 P1.S1/#3709) ──
|
|
391
405
|
usageEvent: {
|
|
392
406
|
create: jest.fn(),
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file agent-store.test.ts
|
|
3
|
+
*
|
|
4
|
+
* `lib/agent-store.ts` — the agents table's ONE data-access boundary (#3591,
|
|
5
|
+
* spec docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md D1, D2, D12).
|
|
6
|
+
*
|
|
7
|
+
* Each function against the Prisma mock. What it pins:
|
|
8
|
+
* - the key: `ailk_agt_` + 43 base64url characters, a fresh one per call,
|
|
9
|
+
* stored only as its SHA-256 digest plus a 17-character prefix;
|
|
10
|
+
* - every read is keyed by a key's digest, an agent id, or an organization
|
|
11
|
+
* id the CALLER passes — never a scope of its own choosing;
|
|
12
|
+
* - `createAgent` derives all three references from its own reads, in one
|
|
13
|
+
* transaction, and refuses (not found) a workspace of another
|
|
14
|
+
* organization or a person with no membership there (SEC-3591-15);
|
|
15
|
+
* - re-keying and switching are scoped by organization: another
|
|
16
|
+
* organization's agent is "not found", and nothing is written.
|
|
17
|
+
*
|
|
18
|
+
* Test fixtures never hold a key-shaped literal: every `ailk_agt_` value is
|
|
19
|
+
* built at run time by `mintAgentKey()`, or the repo-wide secret scan flags it.
|
|
20
|
+
*/
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
|
|
23
|
+
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
|
|
24
|
+
jest.mock("../prisma.js");
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
AGENT_KEY_PREFIX,
|
|
28
|
+
createAgent,
|
|
29
|
+
createServedAgent,
|
|
30
|
+
findAgentByKeyHash,
|
|
31
|
+
findAgentContext,
|
|
32
|
+
hashAgentKey,
|
|
33
|
+
listAgentsForOrganization,
|
|
34
|
+
mintAgentKey,
|
|
35
|
+
replaceAgentKey,
|
|
36
|
+
replaceServedAgentKey,
|
|
37
|
+
setAgentEnabled,
|
|
38
|
+
setServedAgentEnabled,
|
|
39
|
+
touchAgentLastActive,
|
|
40
|
+
} from "../agent-store.js";
|
|
41
|
+
import { prisma } from "../prisma.js";
|
|
42
|
+
|
|
43
|
+
const ORG_A = "org_00000000-0000-7000-8000-000000000aaa";
|
|
44
|
+
const ORG_B = "org_00000000-0000-7000-8000-000000000bbb";
|
|
45
|
+
const WS_A1 = "wksp_00000000-0000-7000-8000-0000000000a1";
|
|
46
|
+
const WS_A2 = "wksp_00000000-0000-7000-8000-0000000000a2";
|
|
47
|
+
const WS_B1 = "wksp_00000000-0000-7000-8000-0000000000b1";
|
|
48
|
+
const OWNER = "user_00000000-0000-7000-8000-0000000000f1";
|
|
49
|
+
const PERSON = "user_00000000-0000-7000-8000-0000000000f2";
|
|
50
|
+
const OWNER_MEMBERSHIP = "member_00000000-0000-7000-8000-0000000000f1";
|
|
51
|
+
const PERSON_MEMBERSHIP = "member_00000000-0000-7000-8000-0000000000f2";
|
|
52
|
+
const AGENT = "agent_00000000-0000-7000-8000-0000000000c1";
|
|
53
|
+
|
|
54
|
+
const KEY_SHAPE = /^ailk_agt_[A-Za-z0-9_-]{43}$/;
|
|
55
|
+
const sha256 = (value: string) => createHash("sha256").update(value).digest("hex");
|
|
56
|
+
|
|
57
|
+
const WORKSPACES: Record<string, { id: string; organizationId: string }> = {
|
|
58
|
+
[WS_A1]: { id: WS_A1, organizationId: ORG_A },
|
|
59
|
+
[WS_A2]: { id: WS_A2, organizationId: ORG_A },
|
|
60
|
+
[WS_B1]: { id: WS_B1, organizationId: ORG_B },
|
|
61
|
+
};
|
|
62
|
+
const MEMBERSHIPS = [
|
|
63
|
+
{ id: OWNER_MEMBERSHIP, organizationId: ORG_A, userId: OWNER },
|
|
64
|
+
{ id: PERSON_MEMBERSHIP, organizationId: ORG_A, userId: PERSON },
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const AGENT_ROW = {
|
|
68
|
+
id: AGENT,
|
|
69
|
+
name: "Inbox helper",
|
|
70
|
+
organizationId: ORG_A,
|
|
71
|
+
workspaceId: WS_A1,
|
|
72
|
+
responsibleMembershipId: PERSON_MEMBERSHIP,
|
|
73
|
+
permissionLevel: "member",
|
|
74
|
+
enabled: true,
|
|
75
|
+
lastActiveAt: null,
|
|
76
|
+
createdAt: new Date("2026-10-04T00:00:00Z"),
|
|
77
|
+
credentialPrefix: "ailk_agt_abcdefgh",
|
|
78
|
+
createdByUserId: OWNER,
|
|
79
|
+
keyIssuedToMembershipId: OWNER_MEMBERSHIP,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function installTenancy(): void {
|
|
83
|
+
(prisma.workspace.findUnique as jest.Mock).mockImplementation(
|
|
84
|
+
({ where }: { where: { id: string } }) => Promise.resolve(WORKSPACES[where.id] ?? null),
|
|
85
|
+
);
|
|
86
|
+
(prisma.membership.findUnique as jest.Mock).mockImplementation(
|
|
87
|
+
({ where }: { where: { organizationId_userId?: { organizationId: string; userId: string } } }) => {
|
|
88
|
+
const key = where.organizationId_userId;
|
|
89
|
+
return Promise.resolve(
|
|
90
|
+
MEMBERSHIPS.find(
|
|
91
|
+
(m) => key && m.organizationId === key.organizationId && m.userId === key.userId,
|
|
92
|
+
) ?? null,
|
|
93
|
+
);
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
// The interactive transaction runs its callback against the same mock.
|
|
97
|
+
(prisma.$transaction as jest.Mock).mockImplementation((fn: (tx: typeof prisma) => unknown) =>
|
|
98
|
+
fn(prisma),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
jest.clearAllMocks();
|
|
104
|
+
installTenancy();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("the key (D2)", () => {
|
|
108
|
+
it("mints ailk_agt_ plus 32 random bytes in base64url, fresh every call", () => {
|
|
109
|
+
const a = mintAgentKey();
|
|
110
|
+
const b = mintAgentKey();
|
|
111
|
+
expect(a).toMatch(KEY_SHAPE);
|
|
112
|
+
expect(b).toMatch(KEY_SHAPE);
|
|
113
|
+
expect(a).not.toBe(b);
|
|
114
|
+
expect(AGENT_KEY_PREFIX).toBe("ailk_agt_");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("hashes the TRIMMED key to lowercase hex SHA-256", () => {
|
|
118
|
+
const key = mintAgentKey();
|
|
119
|
+
expect(hashAgentKey(key)).toBe(sha256(key));
|
|
120
|
+
expect(hashAgentKey(` ${key}\n`)).toBe(sha256(key));
|
|
121
|
+
expect(hashAgentKey(key)).toMatch(/^[0-9a-f]{64}$/);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("findAgentByKeyHash", () => {
|
|
126
|
+
it("is one findUnique by digest, selecting what the guard needs and never the digest", async () => {
|
|
127
|
+
(prisma.agent.findUnique as jest.Mock).mockResolvedValue({
|
|
128
|
+
id: AGENT,
|
|
129
|
+
workspaceId: WS_A1,
|
|
130
|
+
organizationId: ORG_A,
|
|
131
|
+
permissionLevel: "member",
|
|
132
|
+
enabled: true,
|
|
133
|
+
keyIssuedToMembershipId: OWNER_MEMBERSHIP,
|
|
134
|
+
lastActiveAt: null,
|
|
135
|
+
responsible: { id: PERSON_MEMBERSHIP, userId: PERSON },
|
|
136
|
+
});
|
|
137
|
+
const hash = sha256("x");
|
|
138
|
+
|
|
139
|
+
const row = await findAgentByKeyHash(hash);
|
|
140
|
+
|
|
141
|
+
expect(prisma.agent.findUnique).toHaveBeenCalledTimes(1);
|
|
142
|
+
expect(prisma.agent.findUnique).toHaveBeenCalledWith({
|
|
143
|
+
where: { credentialHash: hash },
|
|
144
|
+
select: {
|
|
145
|
+
id: true,
|
|
146
|
+
workspaceId: true,
|
|
147
|
+
organizationId: true,
|
|
148
|
+
permissionLevel: true,
|
|
149
|
+
enabled: true,
|
|
150
|
+
keyIssuedToMembershipId: true,
|
|
151
|
+
lastActiveAt: true,
|
|
152
|
+
responsible: { select: { id: true, userId: true } },
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
expect(row).toEqual({
|
|
156
|
+
id: AGENT,
|
|
157
|
+
workspaceId: WS_A1,
|
|
158
|
+
organizationId: ORG_A,
|
|
159
|
+
permissionLevel: "member",
|
|
160
|
+
enabled: true,
|
|
161
|
+
keyIssuedToMembershipId: OWNER_MEMBERSHIP,
|
|
162
|
+
lastActiveAt: null,
|
|
163
|
+
responsible: { id: PERSON_MEMBERSHIP, userId: PERSON },
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("is null for an unknown digest", async () => {
|
|
168
|
+
(prisma.agent.findUnique as jest.Mock).mockResolvedValue(null);
|
|
169
|
+
await expect(findAgentByKeyHash(sha256("nope"))).resolves.toBeNull();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("touchAgentLastActive", () => {
|
|
174
|
+
it("stamps lastActiveAt on the one agent, by id", async () => {
|
|
175
|
+
(prisma.agent.update as jest.Mock).mockResolvedValue({});
|
|
176
|
+
const at = new Date("2026-10-05T12:00:00Z");
|
|
177
|
+
await touchAgentLastActive(AGENT, at);
|
|
178
|
+
expect(prisma.agent.update).toHaveBeenCalledWith({
|
|
179
|
+
where: { id: AGENT },
|
|
180
|
+
data: { lastActiveAt: at },
|
|
181
|
+
select: { id: true },
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("createAgent (D12 — the references always agree)", () => {
|
|
187
|
+
beforeEach(() => {
|
|
188
|
+
(prisma.agent.create as jest.Mock).mockImplementation(({ data }: { data: Record<string, unknown> }) =>
|
|
189
|
+
Promise.resolve({ ...AGENT_ROW, ...data, id: AGENT }),
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("binds to the target workspace, the person's membership and the creator's, and returns the key once", async () => {
|
|
194
|
+
const result = await createAgent({
|
|
195
|
+
organizationId: ORG_A,
|
|
196
|
+
workspaceId: WS_A2,
|
|
197
|
+
responsibleUserId: PERSON,
|
|
198
|
+
createdByUserId: OWNER,
|
|
199
|
+
name: "Inbox helper",
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(result.ok).toBe(true);
|
|
203
|
+
if (!result.ok) return;
|
|
204
|
+
expect(result.key).toMatch(KEY_SHAPE);
|
|
205
|
+
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
|
206
|
+
const { data, select } = (prisma.agent.create as jest.Mock).mock.calls[0][0];
|
|
207
|
+
expect(data).toEqual({
|
|
208
|
+
organizationId: ORG_A,
|
|
209
|
+
workspaceId: WS_A2,
|
|
210
|
+
responsibleMembershipId: PERSON_MEMBERSHIP,
|
|
211
|
+
keyIssuedToMembershipId: OWNER_MEMBERSHIP,
|
|
212
|
+
createdByUserId: OWNER,
|
|
213
|
+
name: "Inbox helper",
|
|
214
|
+
credentialHash: sha256(result.key),
|
|
215
|
+
credentialPrefix: result.key.slice(0, 17),
|
|
216
|
+
});
|
|
217
|
+
// The digest is written, never selected back; the key is never stored.
|
|
218
|
+
expect(select).not.toHaveProperty("credentialHash");
|
|
219
|
+
expect(JSON.stringify(data)).not.toContain(result.key);
|
|
220
|
+
expect(result.agent.workspaceId).toBe(WS_A2);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("reads the organization from the workspace row, never from the caller alone", async () => {
|
|
224
|
+
const result = await createAgent({
|
|
225
|
+
organizationId: ORG_A,
|
|
226
|
+
workspaceId: WS_B1,
|
|
227
|
+
responsibleUserId: PERSON,
|
|
228
|
+
createdByUserId: OWNER,
|
|
229
|
+
name: "Intruder",
|
|
230
|
+
});
|
|
231
|
+
expect(result).toEqual({ ok: false, reason: "not-found" });
|
|
232
|
+
expect(prisma.agent.create).not.toHaveBeenCalled();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it.each([
|
|
236
|
+
["an unknown workspace", { workspaceId: "wksp_00000000-0000-7000-8000-00000000dead" }],
|
|
237
|
+
["a person with no membership in the organization", { responsibleUserId: "user_00000000-0000-7000-8000-00000000dead" }],
|
|
238
|
+
["a creator with no membership in the organization", { createdByUserId: "user_00000000-0000-7000-8000-00000000dead" }],
|
|
239
|
+
])("is not-found for %s, and writes nothing", async (_label, override) => {
|
|
240
|
+
const result = await createAgent({
|
|
241
|
+
organizationId: ORG_A,
|
|
242
|
+
workspaceId: WS_A1,
|
|
243
|
+
responsibleUserId: PERSON,
|
|
244
|
+
createdByUserId: OWNER,
|
|
245
|
+
name: "Helper",
|
|
246
|
+
...override,
|
|
247
|
+
});
|
|
248
|
+
expect(result).toEqual({ ok: false, reason: "not-found" });
|
|
249
|
+
expect(prisma.agent.create).not.toHaveBeenCalled();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("looks the person up by the organization's compound key, not by any id the caller names", async () => {
|
|
253
|
+
await createAgent({
|
|
254
|
+
organizationId: ORG_A,
|
|
255
|
+
workspaceId: WS_A1,
|
|
256
|
+
responsibleUserId: PERSON,
|
|
257
|
+
createdByUserId: OWNER,
|
|
258
|
+
name: "Helper",
|
|
259
|
+
});
|
|
260
|
+
expect(prisma.membership.findUnique).toHaveBeenCalledWith(
|
|
261
|
+
expect.objectContaining({
|
|
262
|
+
where: { organizationId_userId: { organizationId: ORG_A, userId: PERSON } },
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe("replaceAgentKey (D2)", () => {
|
|
269
|
+
it("writes a new digest and prefix and records who was shown the key, scoped by organization", async () => {
|
|
270
|
+
(prisma.agent.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
|
|
271
|
+
(prisma.agent.findUnique as jest.Mock).mockResolvedValue(AGENT_ROW);
|
|
272
|
+
|
|
273
|
+
const result = await replaceAgentKey({ organizationId: ORG_A, agentId: AGENT, issuedByUserId: OWNER });
|
|
274
|
+
|
|
275
|
+
expect(result.ok).toBe(true);
|
|
276
|
+
if (!result.ok) return;
|
|
277
|
+
expect(result.key).toMatch(KEY_SHAPE);
|
|
278
|
+
expect(prisma.agent.updateMany).toHaveBeenCalledWith({
|
|
279
|
+
where: { id: AGENT, organizationId: ORG_A },
|
|
280
|
+
data: {
|
|
281
|
+
credentialHash: sha256(result.key),
|
|
282
|
+
credentialPrefix: result.key.slice(0, 17),
|
|
283
|
+
keyIssuedToMembershipId: OWNER_MEMBERSHIP,
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
expect(result.agent.id).toBe(AGENT);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("is not-found for another organization's agent", async () => {
|
|
290
|
+
(prisma.agent.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
|
|
291
|
+
const result = await replaceAgentKey({ organizationId: ORG_B, agentId: AGENT, issuedByUserId: OWNER });
|
|
292
|
+
expect(result).toEqual({ ok: false, reason: "not-found" });
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("is not-found when the re-keying person holds no membership in the organization", async () => {
|
|
296
|
+
const result = await replaceAgentKey({
|
|
297
|
+
organizationId: ORG_A,
|
|
298
|
+
agentId: AGENT,
|
|
299
|
+
issuedByUserId: "user_00000000-0000-7000-8000-00000000dead",
|
|
300
|
+
});
|
|
301
|
+
expect(result).toEqual({ ok: false, reason: "not-found" });
|
|
302
|
+
expect(prisma.agent.updateMany).not.toHaveBeenCalled();
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe("setAgentEnabled", () => {
|
|
307
|
+
it("switches the agent, scoped by organization", async () => {
|
|
308
|
+
(prisma.agent.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
|
|
309
|
+
(prisma.agent.findUnique as jest.Mock).mockResolvedValue({ ...AGENT_ROW, enabled: false });
|
|
310
|
+
|
|
311
|
+
const agent = await setAgentEnabled({ organizationId: ORG_A, agentId: AGENT, enabled: false });
|
|
312
|
+
|
|
313
|
+
expect(prisma.agent.updateMany).toHaveBeenCalledWith({
|
|
314
|
+
where: { id: AGENT, organizationId: ORG_A },
|
|
315
|
+
data: { enabled: false },
|
|
316
|
+
});
|
|
317
|
+
expect(agent?.enabled).toBe(false);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("is null for another organization's agent", async () => {
|
|
321
|
+
(prisma.agent.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
|
|
322
|
+
await expect(
|
|
323
|
+
setAgentEnabled({ organizationId: ORG_B, agentId: AGENT, enabled: false }),
|
|
324
|
+
).resolves.toBeNull();
|
|
325
|
+
expect(prisma.agent.findUnique).not.toHaveBeenCalled();
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// ── The management routes' writes — the row they serve, from one transaction ──
|
|
330
|
+
|
|
331
|
+
describe("the served writes (route-facing)", () => {
|
|
332
|
+
const LIST_ROW = {
|
|
333
|
+
...AGENT_ROW,
|
|
334
|
+
responsible: { userId: PERSON, user: { name: "Pat", email: "pat@example.test" } },
|
|
335
|
+
keyIssuedTo: { userId: OWNER, user: { name: "Olive" } },
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
beforeEach(() => {
|
|
339
|
+
(prisma.agent.findMany as jest.Mock).mockResolvedValue([LIST_ROW]);
|
|
340
|
+
(prisma.user.findMany as jest.Mock).mockResolvedValue([{ id: OWNER, name: "Olive" }]);
|
|
341
|
+
(prisma.agent.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
|
|
342
|
+
(prisma.agent.create as jest.Mock).mockImplementation(({ data }: { data: Record<string, unknown> }) =>
|
|
343
|
+
Promise.resolve({ ...AGENT_ROW, ...data, id: AGENT }),
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("each runs its write and its list-row read in one transaction, and serves the list row", async () => {
|
|
348
|
+
const created = await createServedAgent({
|
|
349
|
+
organizationId: ORG_A,
|
|
350
|
+
workspaceId: WS_A1,
|
|
351
|
+
responsibleUserId: PERSON,
|
|
352
|
+
createdByUserId: OWNER,
|
|
353
|
+
name: "Inbox helper",
|
|
354
|
+
});
|
|
355
|
+
const rekeyed = await replaceServedAgentKey({
|
|
356
|
+
organizationId: ORG_A,
|
|
357
|
+
agentId: AGENT,
|
|
358
|
+
issuedByUserId: OWNER,
|
|
359
|
+
});
|
|
360
|
+
const switched = await setServedAgentEnabled({
|
|
361
|
+
organizationId: ORG_A,
|
|
362
|
+
agentId: AGENT,
|
|
363
|
+
enabled: false,
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
expect(prisma.$transaction).toHaveBeenCalledTimes(3);
|
|
367
|
+
for (const result of [created, rekeyed, switched]) {
|
|
368
|
+
expect(result).toMatchObject({
|
|
369
|
+
ok: true,
|
|
370
|
+
agent: { id: AGENT, responsible: { userId: PERSON, email: "pat@example.test" } },
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
expect(created.ok && created.key).toMatch(KEY_SHAPE);
|
|
374
|
+
expect(rekeyed.ok && rekeyed.key).toMatch(KEY_SHAPE);
|
|
375
|
+
for (const call of (prisma.agent.findMany as jest.Mock).mock.calls) {
|
|
376
|
+
expect(call[0].where).toEqual({ id: AGENT, organizationId: ORG_A });
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("re-key and switch match the agent by organization and id only, whichever workspace it is bound to", async () => {
|
|
381
|
+
await replaceServedAgentKey({ organizationId: ORG_A, agentId: AGENT, issuedByUserId: OWNER });
|
|
382
|
+
await setServedAgentEnabled({ organizationId: ORG_A, agentId: AGENT, enabled: true });
|
|
383
|
+
|
|
384
|
+
for (const call of (prisma.agent.updateMany as jest.Mock).mock.calls) {
|
|
385
|
+
expect(call[0].where).toEqual({ id: AGENT, organizationId: ORG_A });
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("another organization's agent is not-found and no row is read", async () => {
|
|
390
|
+
(prisma.agent.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
|
|
391
|
+
|
|
392
|
+
await expect(
|
|
393
|
+
replaceServedAgentKey({ organizationId: ORG_B, agentId: AGENT, issuedByUserId: OWNER }),
|
|
394
|
+
).resolves.toEqual({ ok: false, reason: "not-found" });
|
|
395
|
+
await expect(
|
|
396
|
+
setServedAgentEnabled({ organizationId: ORG_B, agentId: AGENT, enabled: false }),
|
|
397
|
+
).resolves.toEqual({ ok: false, reason: "not-found" });
|
|
398
|
+
expect(prisma.agent.findMany).not.toHaveBeenCalled();
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
describe("listAgentsForOrganization", () => {
|
|
403
|
+
beforeEach(() => {
|
|
404
|
+
(prisma.agent.findMany as jest.Mock).mockResolvedValue([
|
|
405
|
+
{
|
|
406
|
+
...AGENT_ROW,
|
|
407
|
+
responsible: { userId: PERSON, user: { name: "Pat", email: "pat@example.test" } },
|
|
408
|
+
keyIssuedTo: { userId: OWNER, user: { name: "Olive" } },
|
|
409
|
+
},
|
|
410
|
+
]);
|
|
411
|
+
(prisma.user.findMany as jest.Mock).mockResolvedValue([{ id: OWNER, name: "Olive" }]);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("lists the organization's agents, never selecting the digest", async () => {
|
|
415
|
+
const rows = await listAgentsForOrganization(ORG_A);
|
|
416
|
+
const args = (prisma.agent.findMany as jest.Mock).mock.calls[0][0];
|
|
417
|
+
expect(args.where).toEqual({ organizationId: ORG_A });
|
|
418
|
+
expect(args.select).not.toHaveProperty("credentialHash");
|
|
419
|
+
expect(rows).toEqual([
|
|
420
|
+
{
|
|
421
|
+
id: AGENT,
|
|
422
|
+
name: "Inbox helper",
|
|
423
|
+
workspaceId: WS_A1,
|
|
424
|
+
responsible: { userId: PERSON, name: "Pat", email: "pat@example.test" },
|
|
425
|
+
permissionLevel: "member",
|
|
426
|
+
enabled: true,
|
|
427
|
+
lastActiveAt: null,
|
|
428
|
+
createdAt: AGENT_ROW.createdAt,
|
|
429
|
+
keyPrefix: "ailk_agt_abcdefgh",
|
|
430
|
+
createdBy: { userId: OWNER, name: "Olive" },
|
|
431
|
+
keyIssuedTo: { userId: OWNER, name: "Olive" },
|
|
432
|
+
},
|
|
433
|
+
]);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("narrows to one person's agents inside the same organization", async () => {
|
|
437
|
+
await listAgentsForOrganization(ORG_A, PERSON);
|
|
438
|
+
expect((prisma.agent.findMany as jest.Mock).mock.calls[0][0].where).toEqual({
|
|
439
|
+
organizationId: ORG_A,
|
|
440
|
+
responsible: { userId: PERSON },
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
describe("findAgentContext", () => {
|
|
446
|
+
it("reads one agent by id with its person's name and address", async () => {
|
|
447
|
+
(prisma.agent.findUnique as jest.Mock).mockResolvedValue({
|
|
448
|
+
id: AGENT,
|
|
449
|
+
name: "Inbox helper",
|
|
450
|
+
workspaceId: WS_A1,
|
|
451
|
+
permissionLevel: "member",
|
|
452
|
+
responsible: { user: { name: "Pat", email: "pat@example.test" } },
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const context = await findAgentContext(AGENT);
|
|
456
|
+
|
|
457
|
+
expect((prisma.agent.findUnique as jest.Mock).mock.calls[0][0].where).toEqual({ id: AGENT });
|
|
458
|
+
expect(context).toEqual({
|
|
459
|
+
agent: { id: AGENT, name: "Inbox helper", workspaceId: WS_A1, permissionLevel: "member" },
|
|
460
|
+
responsible: { name: "Pat", email: "pat@example.test" },
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("is null for an unknown agent", async () => {
|
|
465
|
+
(prisma.agent.findUnique as jest.Mock).mockResolvedValue(null);
|
|
466
|
+
await expect(findAgentContext(AGENT)).resolves.toBeNull();
|
|
467
|
+
});
|
|
468
|
+
});
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The hub's spoke registry boundary (#7532, spec
|
|
5
5
|
* docs/specs/7532-a-spoke-s-public-reads-spec.md D2, D7, AC-5): minting a key,
|
|
6
|
-
* its digest, the
|
|
7
|
-
*
|
|
6
|
+
* its digest, the two reads the resolver makes (the key lookup, and whether
|
|
7
|
+
* the deployment is a hub — #7633 D1), and the three operator functions the
|
|
8
|
+
* `spokes` CLI calls.
|
|
8
9
|
*
|
|
9
10
|
* External dependency boundary mocked:
|
|
10
11
|
* - @working-theory/database (via the prisma singleton; __mocks__/prisma.ts)
|
|
@@ -19,6 +20,7 @@ import { prisma } from "../prisma.js";
|
|
|
19
20
|
import {
|
|
20
21
|
SPOKE_KEY_PREFIX,
|
|
21
22
|
findSpokeByKeyHash,
|
|
23
|
+
hasRegisteredSpoke,
|
|
22
24
|
hashSpokeKey,
|
|
23
25
|
listSpokes,
|
|
24
26
|
mintSpokeKey,
|
|
@@ -77,6 +79,34 @@ describe("findSpokeByKeyHash (D3 step 5)", () => {
|
|
|
77
79
|
});
|
|
78
80
|
});
|
|
79
81
|
|
|
82
|
+
describe("hasRegisteredSpoke (#7633 D1, AC-5)", () => {
|
|
83
|
+
it("is true when the registry holds a row, revoked or not — one findFirst with no where", async () => {
|
|
84
|
+
db.spoke.findFirst.mockResolvedValue({ id: S1 } as never);
|
|
85
|
+
|
|
86
|
+
await expect(hasRegisteredSpoke()).resolves.toBe(true);
|
|
87
|
+
expect(db.spoke.findFirst).toHaveBeenCalledTimes(1);
|
|
88
|
+
expect(db.spoke.findFirst).toHaveBeenCalledWith({ select: { id: true } });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("is false when the registry is empty", async () => {
|
|
92
|
+
db.spoke.findFirst.mockResolvedValue(null);
|
|
93
|
+
await expect(hasRegisteredSpoke()).resolves.toBe(false);
|
|
94
|
+
expect(db.spoke.findFirst).toHaveBeenCalledWith({ select: { id: true } });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("caches nothing: two calls are two reads", async () => {
|
|
98
|
+
db.spoke.findFirst.mockResolvedValue(null);
|
|
99
|
+
await hasRegisteredSpoke();
|
|
100
|
+
await hasRegisteredSpoke();
|
|
101
|
+
expect(db.spoke.findFirst).toHaveBeenCalledTimes(2);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("propagates a failed read — never answers \"not a hub\"", async () => {
|
|
105
|
+
db.spoke.findFirst.mockRejectedValue(new Error("connection reset"));
|
|
106
|
+
await expect(hasRegisteredSpoke()).rejects.toThrow("connection reset");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
80
110
|
describe("registerSpoke (D7, AC-5)", () => {
|
|
81
111
|
it("stores the key's digest and a 17-character prefix, never the key", async () => {
|
|
82
112
|
db.workspace.findUnique.mockResolvedValue({ id: W1 } as never);
|