@things-factory/headless-twin 10.1.3 → 10.1.4

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.
@@ -0,0 +1,81 @@
1
+ /*
2
+ * Two properties of the dispatch that follows an approval, guarded by reading its source.
3
+ *
4
+ * ── Why source and not behaviour ───────────────────────────────────────────
5
+ * `dispatchAfterApproval` runs outside the request, touches the database and reaches an
6
+ * adapter that talks to a factory. It cannot be exercised here. What can be guarded is the
7
+ * shape of the two decisions that make it safe, and both are visible in the text.
8
+ *
9
+ * ── Why the guard lives here now (2026-09-08) ──────────────────────────────
10
+ * These two assertions were in `operato-twin/test/actuation-gate-walk.test.ts`, reading
11
+ * `../../headless-twin/server/…`. The applications then moved to the operato-application
12
+ * repository and that relative path left this repository — three checks failed with ENOENT.
13
+ *
14
+ * The subject of both is a file in *this* package, so this is where they belong. The half that
15
+ * guards the app's own callback (that approval, and only approval, calls dispatch) stays with
16
+ * the app. A guard reads its own repository; when it has to read across one, the seam is in
17
+ * the wrong place.
18
+ */
19
+ import { test } from 'node:test'
20
+ import assert from 'node:assert/strict'
21
+ import { readFileSync } from 'node:fs'
22
+
23
+ import { guardFile } from './guard-roots.js'
24
+
25
+ const PLACE = '../server/service/actuation/dispatch-after-approval.ts'
26
+
27
+ /** The source with comments removed — a term inside a comment is not a use of it. */
28
+ function sourceOf(): string {
29
+ return readFileSync(guardFile(import.meta.url, PLACE), 'utf8')
30
+ }
31
+
32
+ function withoutComments(src: string): string {
33
+ return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '')
34
+ }
35
+
36
+ test('dispatch does not run inside the approval transaction', () => {
37
+ /*
38
+ * Approval happens inside the worklist's transaction. Sending a request to the factory from
39
+ * inside it means a rollback cannot take the request back: the plant keeps the work order
40
+ * and our ledger has the approval undone. What went out over the network was never in the
41
+ * transaction's care.
42
+ *
43
+ * A transaction-bound store is passed the transaction — `databaseCommandStore(tx)`. An
44
+ * unbound one takes no argument. So the argument list is the property.
45
+ */
46
+ const src = withoutComments(sourceOf())
47
+
48
+ assert.match(src, /databaseCommandStore\(\)/, 'dispatch uses a transaction-bound store')
49
+ assert.doesNotMatch(src, /databaseCommandStore\(\s*[a-zA-Z]/, 'dispatch is being handed a transaction store')
50
+ })
51
+
52
+ test('a failed dispatch does not undo the approval, and does not take the process down', () => {
53
+ /*
54
+ * This runs outside the request, in `setImmediate`. A throw there is an unhandled rejection
55
+ * and the server exits — one undelivered actuation would stop everything else.
56
+ *
57
+ * The approval also has to survive an adapter that is down. That is the whole reason
58
+ * approving and dispatching are two steps, and it is a measured property, not a wish:
59
+ * command `67f8765a` was dispatched successfully later, on the same approval.
60
+ */
61
+ const src = sourceOf()
62
+
63
+ assert.match(src, /catch\s*\(/, 'nothing catches a failed dispatch')
64
+ assert.doesNotMatch(
65
+ withoutComments(src),
66
+ /\bthrow\b/,
67
+ 'the dispatch path throws — outside a request that ends the process'
68
+ )
69
+ })
70
+
71
+ test('this guard reads the real file, so a wrong path is red and not green', () => {
72
+ /*
73
+ * The two checks above fail when a pattern stops matching. They would also "pass" against an
74
+ * empty string, so the path itself has to be guarded — that is exactly how these assertions
75
+ * left this repository unnoticed in the first place.
76
+ */
77
+ const src = sourceOf()
78
+
79
+ assert.ok(src.length > 1000, `the dispatch source is ${src.length} chars — the path is wrong`)
80
+ assert.match(src, /dispatchAfterApproval/, 'the file read is not the one this guard names')
81
+ })
@@ -0,0 +1,76 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { fileURLToPath } from 'node:url'
3
+
4
+ /*
5
+ * ═══════════════════════════════════════════════════════════════════════════
6
+ * Where the source-reading guards look, and what happens when a place is gone.
7
+ *
8
+ * ── Why this exists (2026-09-08) ───────────────────────────────────────────
9
+ * Four guards in this package read the *consumer's* source as well as their own, because the
10
+ * defects they catch are committed by callers: a caller that fills a kernel kind with a
11
+ * literal, a caller that reads the journal without saying it is folding, a caller that
12
+ * ingests without writing to the ledger.
13
+ *
14
+ * The applications then moved to their own repository, and `../../operato-twin/server` stopped
15
+ * existing. Two of the four failed with ENOENT. **The other two passed** — their directory
16
+ * walker swallows a missing root:
17
+ *
18
+ * try { entries = readdirSync(dir) } catch { … }
19
+ *
20
+ * So half of each of those guards was scanning nothing and reporting green. A guard that
21
+ * quietly stops guarding is worse than one that fails, because the failure is the only signal
22
+ * that it was ever load-bearing.
23
+ *
24
+ * ── So a missing root is an error, not a skip ──────────────────────────────
25
+ * `guardRoots` refuses to hand back a root that is not there. The message says which place is
26
+ * gone and what to do about it, because the answer is not "delete the root" — the code being
27
+ * guarded still exists, it is just in another repository now, and the guard has to follow it
28
+ * there.
29
+ * ═══════════════════════════════════════════════════════════════════════════
30
+ */
31
+
32
+ /**
33
+ * Resolve the places a guard scans, and fail loudly if any of them is missing.
34
+ *
35
+ * @param base `import.meta.url` of the calling test.
36
+ * @param places Paths relative to that test file.
37
+ */
38
+ export function guardRoots(base: string, places: readonly string[]): string[] {
39
+ const missing: string[] = []
40
+ const roots = places.map(place => {
41
+ const path = fileURLToPath(new URL(place, base))
42
+ if (!existsSync(path)) missing.push(place)
43
+ return path
44
+ })
45
+
46
+ if (missing.length) {
47
+ throw new Error(
48
+ `guard root missing: ${missing.join(' · ')}\n` +
49
+ 'This guard reads a consumer\'s source, and that source is no longer at this path.\n' +
50
+ 'The applications moved to the operato-application repository (ADR-0041 era). Move the\n' +
51
+ 'consumer half of this guard there rather than deleting the root — a root removed here\n' +
52
+ 'is a rule that stops being checked, and nothing will say so.'
53
+ )
54
+ }
55
+
56
+ return roots
57
+ }
58
+
59
+ /**
60
+ * Resolve one file a guard reads, and fail loudly if it is gone.
61
+ *
62
+ * Same reason as above, for the guards that read a single named file rather than walking a
63
+ * tree. `readFileSync` would already throw here — this only replaces `ENOENT: no such file`
64
+ * with a sentence that says what to do.
65
+ */
66
+ export function guardFile(base: string, place: string): string {
67
+ const path = fileURLToPath(new URL(place, base))
68
+ if (!existsSync(path)) {
69
+ throw new Error(
70
+ `guard file missing: ${place}\n` +
71
+ 'This guard reads a consumer\'s source. That file moved to the operato-application\n' +
72
+ 'repository — move this assertion there rather than deleting it.'
73
+ )
74
+ }
75
+ return path
76
+ }
@@ -14,11 +14,10 @@ import assert from 'node:assert/strict'
14
14
  import { readFileSync, readdirSync, statSync } from 'node:fs'
15
15
  import { join } from 'node:path'
16
16
 
17
+ import { guardRoots } from './guard-roots.ts'
18
+
17
19
  /** 훑는 곳 — 이 저장소에서 인제스트를 부를 수 있는 층 둘. */
18
- const ROOTS = [
19
- new URL('../server', import.meta.url).pathname,
20
- new URL('../../operato-twin/server', import.meta.url).pathname
21
- ]
20
+ const ROOTS = guardRoots(import.meta.url, ['../server'])
22
21
 
23
22
  /** 인제스트를 여는 함수 이름. 이 이름이 나오는 자리가 곧 유입 경로다. */
24
23
  const INGEST_CALL = 'ingestCanonicalRecords('
@@ -24,11 +24,10 @@ import assert from 'node:assert/strict'
24
24
  import { readFileSync, readdirSync, statSync } from 'node:fs'
25
25
  import { join } from 'node:path'
26
26
 
27
+ import { guardRoots } from './guard-roots.ts'
28
+
27
29
  /** 훑는 곳 — 저널을 읽을 수 있는 층 둘. */
28
- const ROOTS = [
29
- new URL('../server', import.meta.url).pathname,
30
- new URL('../../operato-twin/server', import.meta.url).pathname
31
- ]
30
+ const ROOTS = guardRoots(import.meta.url, ['../server'])
32
31
 
33
32
  const READ = 'getRepository(TwinEvent)'
34
33
  /** 폴드라고 밝히는 표식. 이유를 함께 적는 것이 이 표식의 값이다. */
@@ -25,6 +25,8 @@ import assert from 'node:assert/strict'
25
25
  import { readdirSync, readFileSync, statSync } from 'node:fs'
26
26
  import { join } from 'node:path'
27
27
 
28
+ import { guardRoots } from './guard-roots.ts'
29
+
28
30
  const ENGINE = new URL('../server/engine/twin-engine.ts', import.meta.url).pathname
29
31
  const MUTATION = new URL('../server/service/twin-lifecycle/twin-lifecycle-mutation.ts', import.meta.url).pathname
30
32
 
@@ -34,7 +36,7 @@ const MUTATION = new URL('../server/service/twin-lifecycle/twin-lifecycle-mutati
34
36
  * `det.kind ?? 'wms'` 로 **엔진이 오류를 내기 전에 먼저 값을 정하고** 있었다. 커넥터 spec 도 마찬가지였다.
35
37
  * 그래서 두 패키지의 서버 코드를 함께 훑는다.
36
38
  */
37
- const ROOTS = [new URL('../server', import.meta.url).pathname, new URL('../../operato-twin/server', import.meta.url).pathname]
39
+ const ROOTS = guardRoots(import.meta.url, ['../server'])
38
40
 
39
41
  /**
40
42
  * 코드만 본다 — 주석은 **과거를 설명하느라 옛 코드를 그대로 인용한다**(바로 위 머리말이 그렇다).
@@ -0,0 +1,101 @@
1
+ /*
2
+ * A refusal has to name which envelope was refused.
3
+ *
4
+ * ── What this catches, and why the existing test did not (2026-09-08) ──────
5
+ * `rejectedForCaller` pairs a refused record with its envelope by **object identity** — value
6
+ * comparison would let two identically shaped records take each other's id. Its own test
7
+ * hands the same object to both sides, so it passes and proves the pairing works.
8
+ *
9
+ * It does not prove anything about the real path, because in between
10
+ * `ingestCanonicalRecords` builds `{ ...r, eventTime, sourceType }` — **a new object**. The
11
+ * refusal then carries that copy, the copy is not in the identity map, and the response comes
12
+ * back as `{ record, errors }` with no `eventId`:
13
+ *
14
+ * sent {"eventId":"probe-eventid-check-001","record":{"kind":"transformation"}}
15
+ * back rejected: [{ record: {kind:"transformation", sourceType:"twin"}, errors:[…] }]
16
+ *
17
+ * The sending side cannot mark the row that was refused, so it cannot settle the batch and
18
+ * its queue stops moving — measured on the plant side as 719 envelopes pending with nothing
19
+ * lost and nothing advancing.
20
+ *
21
+ * So this test walks the seam the way a real envelope does: through the ingest that makes the
22
+ * copy, then through the pairing. That is the step a pure test of either half cannot take.
23
+ */
24
+ import { test } from 'node:test'
25
+ import assert from 'node:assert/strict'
26
+
27
+ import { ingestCanonicalRecords } from '../server/engine/canonical-ingest.js'
28
+ import { rejectedForCaller } from '../server/service/reference/hook-rejected.js'
29
+
30
+ const AT = '2026-09-08T02:00:00.000Z'
31
+
32
+ /** A transformation the kernel refuses — no inputs, no outputs, no bizStep. */
33
+ const REFUSED = { kind: 'transformation' } as any
34
+
35
+ test('a refusal names the envelope it came from, after ingest has copied the record', () => {
36
+ /* The envelope as the hook holds it: the record object, and the id that identifies its row. */
37
+ const items = [{ record: REFUSED, eventId: 'evt-refused-1' }]
38
+
39
+ const result = ingestCanonicalRecords(
40
+ items.map(i => i.record),
41
+ 'test-domain',
42
+ AT
43
+ ) as any
44
+
45
+ assert.equal(result.rejected.length, 1, 'the kernel refuses this record')
46
+
47
+ const forCaller = rejectedForCaller(result.rejected, items)
48
+
49
+ assert.deepEqual(
50
+ forCaller.map((r: any) => r.eventId),
51
+ ['evt-refused-1'],
52
+ 'the refusal carries the envelope id — without it the sender cannot mark the row'
53
+ )
54
+ })
55
+
56
+ test('two refused records of the same shape keep their own ids', () => {
57
+ /*
58
+ * This is why the pairing is by identity and not by value. Both records look the same, and
59
+ * value comparison would hand the first id to both — the sender would mark one row twice
60
+ * and leave the other pending forever.
61
+ */
62
+ const a = { kind: 'transformation' } as any
63
+ const b = { kind: 'transformation' } as any
64
+ const items = [
65
+ { record: a, eventId: 'evt-a' },
66
+ { record: b, eventId: 'evt-b' }
67
+ ]
68
+
69
+ const result = ingestCanonicalRecords(
70
+ items.map(i => i.record),
71
+ 'test-domain',
72
+ AT
73
+ ) as any
74
+ const forCaller = rejectedForCaller(result.rejected, items)
75
+
76
+ assert.deepEqual(
77
+ forCaller.map((r: any) => r.eventId).sort(),
78
+ ['evt-a', 'evt-b'],
79
+ 'each refusal keeps its own id'
80
+ )
81
+ })
82
+
83
+ test('an envelope with no id still says what was refused', () => {
84
+ /*
85
+ * A connector that does not number its envelopes gets the record back instead. An empty id
86
+ * would be worse than none: the sender would look for a row, find nothing, and treat the
87
+ * batch as understood.
88
+ */
89
+ const items = [{ record: REFUSED }]
90
+
91
+ const result = ingestCanonicalRecords(
92
+ items.map(i => i.record),
93
+ 'test-domain',
94
+ AT
95
+ ) as any
96
+ const forCaller = rejectedForCaller(result.rejected, items) as any[]
97
+
98
+ assert.equal(forCaller.length, 1)
99
+ assert.equal('eventId' in forCaller[0], false, 'no id is sent rather than an empty one')
100
+ assert.ok('record' in forCaller[0], 'the record is sent so a person can see what was dropped')
101
+ })
@@ -0,0 +1,135 @@
1
+ /*
2
+ * A column that can hold a credential has to be encrypted at rest.
3
+ *
4
+ * ── What this catches, and what it cost to find by hand (2026-09-09) ──────
5
+ * `TwinReference.connectionConfig` was a plain `simple-json` column holding a signing secret
6
+ * and a live access token. Two shell commands read both:
7
+ *
8
+ * sqlite3 -readonly db.sqlite "select connection_config from twin_references"
9
+ * {"endpoint":"…","hookSecret":"dev-only-localhost-hook-2026","token":"eyJhbGciOi…"}
10
+ *
11
+ * The read path was already guarded — the detail resolver masks fields an adapter declares
12
+ * `secret: true`. So the leak was not on the way out, it was the stored bytes: the database
13
+ * file, its backups, its replicas. **Two different protections, and having one reads exactly
14
+ * like having both.**
15
+ *
16
+ * ── Why a guard and not a review habit ────────────────────────────────────
17
+ * The framework has had the pieces for a while — `encryptJsonTransformer` and
18
+ * `longTextColumn` — and nothing used them together. Two packages imported the transformer and
19
+ * left it commented out. A rule that depends on someone remembering two exports is a rule that
20
+ * holds until the next new column.
21
+ *
22
+ * So the rule is measured instead: any column whose name says it can carry a credential must
23
+ * carry an encrypting transformer. New entities are covered the day they are written.
24
+ *
25
+ * ── What this does not claim ──────────────────────────────────────────────
26
+ * Encryption at rest does not protect a value from anyone who can run this process — that
27
+ * person holds the key. It protects the stored copy, which is a different set of people.
28
+ */
29
+ import { test } from 'node:test'
30
+ import assert from 'node:assert/strict'
31
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
32
+ import { fileURLToPath } from 'node:url'
33
+ import { join } from 'node:path'
34
+
35
+ const SERVER = fileURLToPath(new URL('../server', import.meta.url))
36
+
37
+ /**
38
+ * 이름이 「자격 증명이 들 수 있다」고 말하는 칸.
39
+ *
40
+ * 이름으로 판정하는 것의 한계를 적어 둔다 — `connectionConfig` 처럼 **이름이 비밀을 말하지 않는**
41
+ * 칸이 실제로 비밀을 담고 있었다. 그래서 그 이름을 목록에 넣는다. 새 칸이 같은 부류이면 여기 더한다.
42
+ */
43
+ const CREDENTIAL_NAMES = /^(secret|token|password|passwd|apiKey|credential|credentials|privateKey|connectionConfig)\??$/i
44
+
45
+ /** 암호화를 붙이는 방법 — 둘 중 하나면 된다(shell 이 내놓는 것). */
46
+ const ENCRYPTED = /encryptedJsonColumn\(\)|encryptTransformer|encryptJsonTransformer/
47
+
48
+ function entityFiles(dir: string, out: string[] = []): string[] {
49
+ for (const name of readdirSync(dir)) {
50
+ const full = join(dir, name)
51
+ if (statSync(full).isDirectory()) {
52
+ entityFiles(full, out)
53
+ continue
54
+ }
55
+ if (!full.endsWith('.ts') || full.endsWith('.d.ts')) continue
56
+ const src = readFileSync(full, 'utf-8')
57
+ if (src.includes('@Entity(')) out.push(full)
58
+ }
59
+ return out
60
+ }
61
+
62
+ /** 한 파일에서 `@Column` 선언과 그것이 붙은 속성 이름의 짝. */
63
+ function columnsOf(src: string): { property: string; declaration: string; line: number }[] {
64
+ const lines = src.split('\n')
65
+ const out: { property: string; declaration: string; line: number }[] = []
66
+
67
+ for (let i = 0; i < lines.length; i++) {
68
+ if (!/^\s*@Column\(/.test(lines[i])) continue
69
+
70
+ /* 선언이 여러 줄일 수 있다 — 괄호가 닫힐 때까지 모은다. */
71
+ let declaration = ''
72
+ let depth = 0
73
+ let j = i
74
+ do {
75
+ declaration += lines[j]
76
+ for (const ch of lines[j]) {
77
+ if (ch === '(') depth++
78
+ else if (ch === ')') depth--
79
+ }
80
+ j++
81
+ } while (depth > 0 && j < lines.length && j - i < 12)
82
+
83
+ /* 데코레이터 다음의 첫 속성 줄 — 사이에 @Field 등이 끼어든다. */
84
+ for (let k = j; k < Math.min(j + 6, lines.length); k++) {
85
+ const m = /^\s{2}(?:readonly\s+)?([A-Za-z_][A-Za-z0-9_]*)\??\s*[:!]/.exec(lines[k])
86
+ if (m) {
87
+ out.push({ property: m[1], declaration, line: i + 1 })
88
+ break
89
+ }
90
+ }
91
+ }
92
+ return out
93
+ }
94
+
95
+ test('★ a column that can hold a credential is encrypted at rest', () => {
96
+ const offenders: string[] = []
97
+ let examined = 0
98
+
99
+ for (const file of entityFiles(SERVER)) {
100
+ const src = readFileSync(file, 'utf-8')
101
+ for (const column of columnsOf(src)) {
102
+ examined++
103
+ if (!CREDENTIAL_NAMES.test(column.property)) continue
104
+ if (ENCRYPTED.test(column.declaration)) continue
105
+ offenders.push(`${file.slice(SERVER.length + 1)}:${column.line} ${column.property}`)
106
+ }
107
+ }
108
+
109
+ assert.ok(examined > 40, `본 칸이 ${examined}개다 — 찾는 방법이 깨졌다`)
110
+ assert.deepEqual(
111
+ offenders,
112
+ [],
113
+ 'these columns can hold a credential and are stored in the clear:\n ' +
114
+ offenders.join('\n ') +
115
+ '\nAdd ...encryptedJsonColumn() (json) or transformer: encryptTransformer (string) from @things-factory/shell.\n' +
116
+ 'Note the column type has to change with it — encryptedJsonColumn() carries both, which is why it exists.'
117
+ )
118
+ })
119
+
120
+ test('this guard reads real entities, so a broken scan is red and not green', () => {
121
+ /*
122
+ * 위 검사는 위반이 없으면 통과한다. **아무것도 못 읽어도** 통과하므로, 읽었다는 사실을 따로 잰다 —
123
+ * 오늘 이 저장소의 다른 검사가 견준 것이 0인데 초록이던 부류가 넷이었다.
124
+ */
125
+ const files = entityFiles(SERVER)
126
+ assert.ok(files.length > 10, `엔티티 파일이 ${files.length}개다 — 순회가 깨졌다`)
127
+
128
+ const reference = files.find(f => f.endsWith('twin-reference.ts'))
129
+ assert.ok(reference, 'twin-reference.ts 를 못 찾았다 — 이 검사가 생긴 이유가 그 파일이다')
130
+
131
+ const found = columnsOf(readFileSync(reference!, 'utf-8'))
132
+ const config = found.find(c => c.property === 'connectionConfig')
133
+ assert.ok(config, 'connectionConfig 칸을 못 읽었다 — 짝짓기가 깨졌다')
134
+ assert.match(config!.declaration, ENCRYPTED, 'connectionConfig 가 암호화되어 있지 않다')
135
+ })