/* * Which of headless-twin's doors are open, and why each one is allowed to be. * * ── The thing this catches ────────────────────────────────────────────────── * `@privilege` is opt-in. A root field that declares nothing is recorded as open, and that is * indistinguishable from a field somebody decided to open. The list below turns the absence into a * statement: anything open that is not on it fails, and the reason has to be written first. * Same measure as `auth-base/tests/open-doors.test.ts`. * * ── The decision the reads rest on ────────────────────────────────────────── * ADR-0027: the twin gates **writes** only. Reading is the product's purpose, and the boundary for a * read is tenant membership — so an ungated read is correct only if its body narrows to the caller's * domain. Every entry below says how it does. (The one read that crosses tenants on purpose, * `twinLoad`, is super-user gated and so is not here.) A door that answers from the installation * rather than from any tenant's rows says so instead. * * ── Why this reads the build ──────────────────────────────────────────────── * The schema is built from `service/index`'s own `resolverClasses`, the list the server boots from. * type-graphql needs decorator metadata to build it, and Node's type stripping (how the other tests * here run the source) does not emit it. So this one reads `dist-server`. A stale build would miss a * door added since, and pass — hence the freshness check before anything else. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { createRequire } from 'node:module' import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { dirname, join, relative } from 'node:path' import { fileURLToPath } from 'node:url' const PKG = join(dirname(fileURLToPath(import.meta.url)), '..') const DIST = join(PKG, 'dist-server') /* Resolver sources newer than their build: the schema below would not be the one served. */ function staleResolvers(): string[] { const out: string[] = [] const walk = (dir: string) => { for (const name of readdirSync(dir)) { const p = join(dir, name) if (statSync(p).isDirectory()) walk(p) else if (p.endsWith('.ts') && /@(Query|Mutation|Subscription)\(/.test(readFileSync(p, 'utf8'))) { const built = join(DIST, relative(join(PKG, 'server'), p)).replace(/\.ts$/, '.js') if (!existsSync(built) || statSync(built).mtimeMs < statSync(p).mtimeMs) out.push(relative(PKG, p)) } } } walk(join(PKG, 'server')) return out } const INSTALLATION = 'answers from the installation, not from any tenant\'s rows' const DOMAIN_ROWS = 'reads rows where domain = the caller\'s' const DOMAIN_RUNTIME = 'reads the runtime keyed (caller\'s domain, instanceId)' /** Open on purpose (ADR-0027), each with how it stays inside the caller's tenant. */ const OPEN_BY_DECISION: Record<'Query' | 'Subscription', Record> = { Query: { /* Catalogues — what this build can do. */ referenceAdapterTypes: `${INSTALLATION}: registered connector type keys`, referenceAdapters: `${INSTALLATION}: connector metadata and judged capabilities`, twinDomainCatalog: `${INSTALLATION}: the kernel's location-type vocabulary`, twinTargetMetrics: `${INSTALLATION}: the metric ids a target may name`, twinTemplates: `${INSTALLATION}: registered twin templates and their knobs`, /* Connections and references. */ discoverReferenceSites: `${DOMAIN_ROWS} (TwinReference by source), then asks that connection`, referenceSources: `${DOMAIN_ROWS} (TwinInstance) and the caller's runtimes`, twinConnection: `${DOMAIN_ROWS} (TwinReference, TwinInstance)`, twinConnections: `${DOMAIN_ROWS} (TwinReference, TwinInstance)`, twinReference: `${DOMAIN_ROWS} (TwinReference)`, twinReferences: `${DOMAIN_ROWS} (TwinReference, TwinInstance)`, twinReferenceReconciliation: `${DOMAIN_ROWS} (TwinReferenceReconciliation)`, twinReferenceReconciliations: `${DOMAIN_ROWS} (TwinReferenceReconciliation)`, /* Actuation — reading what was proposed and how it went. Acting is gated. */ twinActuationOpinion: 'loads the command by (caller\'s domain, id); refuses without a domain', twinActuationOutcome: 'loads the command by (caller\'s domain, id); refuses without a domain', twinActuationReadiness: 'loads the command by (caller\'s domain, id); refuses without a domain', twinActuationRules: `${DOMAIN_ROWS} (TwinActuationRule list params with the caller's domain)`, twinCommands: `${DOMAIN_ROWS} (TwinCommandRecord list params with the caller's domain)`, /* Instances and lifecycle. */ twinAdoptionPath: `${DOMAIN_ROWS} after an ownership check`, twinDeletionPreview: `${DOMAIN_ROWS} (TwinInstance, TwinEvent) — describes, deletes nothing`, twinInstanceDetail: `${DOMAIN_ROWS} (TwinEngine.detail)`, twinInstanceList: `${DOMAIN_ROWS} (TwinEngine.list)`, twinInstances: `${DOMAIN_ROWS} (TwinEngine.list)`, twinSourceCapability: `${DOMAIN_RUNTIME}; empty without a domain`, twinSpaceInstanceIds: `${DOMAIN_ROWS} (TwinEngine.instanceIdsOfSpace)`, /* Journal, history, time travel. */ twinAuditList: `${DOMAIN_ROWS} (TwinAuditEvent list params with the caller's domain)`, twinCapacity: `${DOMAIN_RUNTIME} after an ownership check`, twinEventList: `${DOMAIN_ROWS} (TwinEvent) for targets resolved inside the caller's domain`, twinEvents: `${DOMAIN_ROWS} (TwinEvent)`, twinKpi: `${DOMAIN_ROWS} after an ownership check`, twinOperationsCapability: `${DOMAIN_RUNTIME} after an ownership check`, twinReplay: `${DOMAIN_RUNTIME} or its journal (TwinEngine.recover)`, twinStructures: `${DOMAIN_ROWS} (TwinStructure, TwinEvent) after an ownership check`, twinSubjectHistory: `${DOMAIN_ROWS} (TwinSubjectEvent)`, twinTimeRange: `${DOMAIN_ROWS} (TwinInstance, TwinEvent)`, /* Model and entity views. */ spaceDeclarations: `${DOMAIN_ROWS} (TwinSpace, TwinInstance) and the caller's runtimes`, twinDeclarations: `${DOMAIN_ROWS} (TwinInstance, TwinSpace) and the caller's runtime`, twinItemLineage: `${DOMAIN_ROWS} (TwinInstance, TwinEvent)`, twinModelEntities: `${DOMAIN_ROWS} (TwinInstance and the entity tables) or the caller's runtime`, twinModelItem: `${DOMAIN_ROWS} (TwinInstance and the entity tables) or the caller's runtime`, twinModelSummary: `${DOMAIN_ROWS} (TwinInstance, TwinReference, TwinEvent) or the caller's runtime`, twinModelTree: `${DOMAIN_ROWS} (TwinInstance)`, /* Spaces and targets. */ twinAreas: `${DOMAIN_ROWS} (TwinArea)`, twinSpace: `${DOMAIN_ROWS} (TwinSpace, representations, areas)`, twinSpaceIntegrity: `${DOMAIN_ROWS} (every space table)`, twinSpaces: `${DOMAIN_ROWS} (TwinSpace, representations, TwinInstance)`, twinTargets: `${DOMAIN_ROWS} (TwinTarget)`, /* Live health and forecasting — computed from the caller's runtime. */ twinAttentionDigest: `${DOMAIN_RUNTIME} after an ownership check`, twinAttentions: `${DOMAIN_RUNTIME} after an ownership check`, twinForecast: `${DOMAIN_RUNTIME} (forks the caller's kernel)`, twinForecastGapTrend: `${DOMAIN_RUNTIME} for the caller's co-located twins`, twinIngestHealth: `${DOMAIN_RUNTIME} after an ownership check; raw samples need more`, twinIngestHistory: `${DOMAIN_ROWS} (TwinIngestWindow) and the caller's runtime`, twinMetrics: `${DOMAIN_RUNTIME} after an ownership check`, twinMetricsAll: `${DOMAIN_RUNTIME} for every runtime of the caller's domain` }, Subscription: { twinState: 'the caller must own the instance to subscribe (ADR-0027 read)', twinReferenceProgress: 'import progress for one source (ADR-0027 read)' } } const req = createRequire(import.meta.url) let schemaPromise: Promise | undefined function schema(): Promise { const stale = staleResolvers() if (stale.length) { throw new Error( `dist-server is older than ${stale.length} resolver source(s), so the schema below is not the one served:\n ` + stale.join('\n ') + '\nBuild first: yarn workspace @things-factory/headless-twin build:server' ) } schemaPromise ??= (async () => { req('reflect-metadata') const { buildSchema } = req('type-graphql') const { schema: declared } = req(join(DIST, 'service/index.js')) const { privilegeDirectiveResolver } = req('@things-factory/auth-base/dist-server/service/privilege/privilege-directive.js') /* Subscriptions need a PubSub to build. Nothing is published here — only the shape is read. */ const pubSub = { publish() {}, subscribe: () => (async function* () {})() } const built = await buildSchema({ resolvers: declared.resolverClasses, validate: false, pubSub }) privilegeDirectiveResolver(built) return built })() return schemaPromise } const privilegeOfField = (t: string, f: string) => req('@things-factory/auth-base/dist-server/service/privilege/privilege-directive.js').privilegeOfField(t, f) async function fieldsOf(typeName: 'Query' | 'Mutation' | 'Subscription'): Promise { const s = await schema() const type = typeName === 'Query' ? s.getQueryType() : typeName === 'Mutation' ? s.getMutationType() : s.getSubscriptionType() return Object.keys(type?.getFields() ?? {}) } async function openFields(typeName: 'Query' | 'Mutation' | 'Subscription'): Promise { return (await fieldsOf(typeName)).filter(f => { const gate = privilegeOfField(typeName, f) return gate.known && gate.gate === null }) } test('every open read is one somebody chose, with how it stays in the caller\'s tenant', async () => { for (const t of ['Query', 'Subscription'] as const) { assert.deepEqual((await openFields(t)).sort(), Object.keys(OPEN_BY_DECISION[t]).sort(), `${t}: open fields ≠ OPEN_BY_DECISION`) } }) test('no write is open — ADR-0027 gates every mutation', async () => { assert.deepEqual(await openFields('Mutation'), []) }) test('the walk reaches the whole schema, not a handful of fields', async () => { /* If the resolver list shrank to nothing, the two tests above would pass by having nothing to look at. */ assert.ok((await fieldsOf('Query')).length > 50) assert.ok((await fieldsOf('Mutation')).length > 40) assert.ok((await fieldsOf('Subscription')).length >= 2) }) test('an undeclared field is told apart from one the schema does not have', async () => { await schema() /* `known: false` must never read as open — that is how a typo becomes a gate that is not there. */ assert.deepEqual(privilegeOfField('Query', 'noSuchField'), { known: false }) })