@prestack/thread 0.1.21 → 0.1.23
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/CHANGELOG.md +227 -0
- package/dist/core/attach.js +21 -0
- package/dist/core/descend.js +6 -3
- package/dist/core/generators/commit.js +55 -3
- package/dist/core/generators/dev.d.ts +1 -0
- package/dist/core/generators/dev.js +69 -0
- package/dist/derivation/generators/build.d.ts +3 -1
- package/dist/derivation/generators/build.js +104 -22
- package/dist/derivation/generators/release.js +74 -25
- package/dist/derivation/release.js +3 -3
- package/dist/integrations/cli.d.ts +0 -1
- package/dist/integrations/cli.js +69 -11
- package/dist/integrations/declared.d.ts +2 -0
- package/dist/integrations/declared.js +6 -1
- package/dist/integrations/pglite.js +13 -3
- package/dist/integrations/target.d.ts +3 -0
- package/dist/integrity/attach.js +1 -5
- package/dist/integrity/slopnators/sl_stale_path.js +10 -0
- package/dist/operations/access.js +3 -2
- package/dist/operations/capabilities.d.ts +2 -0
- package/dist/operations/capabilities.js +2 -0
- package/dist/operations/generators/capability.js +237 -24
- package/dist/operations/generators/compile.js +150 -50
- package/dist/operations/generators/seed.d.ts +3 -0
- package/dist/operations/generators/seed.js +25 -3
- package/dist/operations/nodes.d.ts +2 -2
- package/package.json +1 -1
- package/dist/core/actor.ts +0 -21
- package/dist/core/attach.ts +0 -613
- package/dist/core/chain.ts +0 -145
- package/dist/core/condition.ts +0 -227
- package/dist/core/descend.ts +0 -1185
- package/dist/core/errors/catalogue.ts +0 -314
- package/dist/core/errors/classes.ts +0 -51
- package/dist/core/errors/error.ts +0 -152
- package/dist/core/errors/namespaces.ts +0 -19
- package/dist/core/finding.ts +0 -37
- package/dist/core/generators/commit.ts +0 -157
- package/dist/core/init.ts +0 -222
- package/dist/core/invoked.ts +0 -52
- package/dist/core/round.ts +0 -74
- package/dist/core/rows.ts +0 -506
- package/dist/core/scope.ts +0 -20
- package/dist/core/thread.ts +0 -29
- package/dist/core/tree.ts +0 -90
- package/dist/core/types.ts +0 -351
- package/dist/derivation/attach.ts +0 -57
- package/dist/derivation/generators/build.ts +0 -371
- package/dist/derivation/generators/check.ts +0 -118
- package/dist/derivation/generators/determinism.ts +0 -145
- package/dist/derivation/generators/release.ts +0 -236
- package/dist/derivation/release.ts +0 -43
- package/dist/integrations/attach.ts +0 -187
- package/dist/integrations/cli.ts +0 -606
- package/dist/integrations/declared.ts +0 -34
- package/dist/integrations/disk.ts +0 -466
- package/dist/integrations/generators/read.ts +0 -127
- package/dist/integrations/generators/vault.ts +0 -210
- package/dist/integrations/https.ts +0 -216
- package/dist/integrations/pglite.ts +0 -206
- package/dist/integrations/secret.ts +0 -54
- package/dist/integrations/target.ts +0 -672
- package/dist/integrations/vault.ts +0 -148
- package/dist/integrity/attach.ts +0 -150
- package/dist/integrity/check-threads.ts +0 -166
- package/dist/integrity/constraint.ts +0 -1246
- package/dist/integrity/generators/compiler.ts +0 -138
- package/dist/integrity/generators/gate.ts +0 -227
- package/dist/integrity/generators/issues.ts +0 -160
- package/dist/integrity/generators/publish.ts +0 -233
- package/dist/integrity/generators/trial.ts +0 -359
- package/dist/integrity/lint.ts +0 -214
- package/dist/integrity/lsp/bin.ts +0 -44
- package/dist/integrity/lsp/diagnostics.ts +0 -92
- package/dist/integrity/lsp/findings.ts +0 -63
- package/dist/integrity/lsp/lint.ts +0 -124
- package/dist/integrity/lsp/protocol.ts +0 -78
- package/dist/integrity/lsp/server.ts +0 -224
- package/dist/integrity/slopnator.ts +0 -324
- package/dist/integrity/slopnators/sl_broken_lineage.ts +0 -38
- package/dist/integrity/slopnators/sl_declared_elsewhere.ts +0 -60
- package/dist/integrity/slopnators/sl_derived_drift.ts +0 -101
- package/dist/integrity/slopnators/sl_door_in_a_node.ts +0 -250
- package/dist/integrity/slopnators/sl_hardcoded_style.ts +0 -55
- package/dist/integrity/slopnators/sl_inert_island.ts +0 -84
- package/dist/integrity/slopnators/sl_inert_slopnator.ts +0 -93
- package/dist/integrity/slopnators/sl_missing_row.ts +0 -140
- package/dist/integrity/slopnators/sl_no_barrel.ts +0 -48
- package/dist/integrity/slopnators/sl_no_child_process.ts +0 -100
- package/dist/integrity/slopnators/sl_no_comment.ts +0 -43
- package/dist/integrity/slopnators/sl_no_dispatch_bypass.ts +0 -276
- package/dist/integrity/slopnators/sl_no_import_cycle.ts +0 -66
- package/dist/integrity/slopnators/sl_no_such_command.ts +0 -81
- package/dist/integrity/slopnators/sl_only_takes_a_literal.ts +0 -63
- package/dist/integrity/slopnators/sl_outside_an_operation.ts +0 -25
- package/dist/integrity/slopnators/sl_screen_holds_only_declared.ts +0 -70
- package/dist/integrity/slopnators/sl_secret_literal.ts +0 -68
- package/dist/integrity/slopnators/sl_stale_path.ts +0 -178
- package/dist/integrity/slopnators/sl_twice_meant.ts +0 -57
- package/dist/integrity/slopnators/sl_twice_parented.ts +0 -82
- package/dist/integrity/slopnators/sl_undeclared_crossing.ts +0 -481
- package/dist/integrity/slopnators/sl_undeclared_destination.ts +0 -113
- package/dist/integrity/slopnators/sl_undeclared_element.ts +0 -44
- package/dist/integrity/slopnators/sl_undeclared_list.ts +0 -358
- package/dist/integrity/slopnators/sl_undeclared_node.ts +0 -75
- package/dist/integrity/slopnators/sl_undeclared_operation.ts +0 -257
- package/dist/integrity/slopnators/sl_undeclared_variant.ts +0 -152
- package/dist/integrity/slopnators/sl_undeclared_webhook.ts +0 -108
- package/dist/integrity/slopnators/sl_underived_route.ts +0 -72
- package/dist/integrity/slopnators/sl_undescribed.ts +0 -65
- package/dist/integrity/slopnators/sl_ungoverned_island.ts +0 -60
- package/dist/integrity/slopnators/sl_unlisted_program.ts +0 -46
- package/dist/integrity/slopnators/sl_unnamed_island.ts +0 -41
- package/dist/integrity/slopnators/sl_unpaired_member.ts +0 -139
- package/dist/integrity/slopnators/sl_unpaired_slopnator.ts +0 -71
- package/dist/integrity/slopnators/sl_unprefixed_member.ts +0 -110
- package/dist/integrity/slopnators/sl_unthreaded.ts +0 -421
- package/dist/integrity/slopnators/sl_untranslated_key.ts +0 -140
- package/dist/integrity/slopnators/sl_unvaulted_secret.ts +0 -132
- package/dist/integrity/slopnators/sl_unversioned.ts +0 -52
- package/dist/integrity/sweep.ts +0 -395
- package/dist/knowledge/attach.ts +0 -327
- package/dist/knowledge/generators/docsgen.ts +0 -186
- package/dist/knowledge/generators/glossary.ts +0 -73
- package/dist/knowledge/glossary.ts +0 -6
- package/dist/operations/access.ts +0 -131
- package/dist/operations/attach.ts +0 -408
- package/dist/operations/capabilities.ts +0 -88
- package/dist/operations/dispatch.ts +0 -294
- package/dist/operations/error-results.ts +0 -31
- package/dist/operations/generators/capability.ts +0 -1054
- package/dist/operations/generators/compile.ts +0 -701
- package/dist/operations/generators/permits.ts +0 -229
- package/dist/operations/generators/seed.ts +0 -359
- package/dist/operations/generators/trial.ts +0 -666
- package/dist/operations/nodes.ts +0 -53
- package/dist/operations/operations.ts +0 -91
- package/dist/operations/result.ts +0 -28
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync } from '../disk.ts'
|
|
2
|
-
import { valuesIn } from '../../core/rows.ts'
|
|
3
|
-
import { dirname, join, relative } from 'node:path'
|
|
4
|
-
import { argv, stderr, stdout } from 'node:process'
|
|
5
|
-
|
|
6
|
-
import ts from 'typescript'
|
|
7
|
-
|
|
8
|
-
import { exit } from '../../core/invoked.ts'
|
|
9
|
-
import { named as packageOf } from '../../core/scope.ts'
|
|
10
|
-
import { findTree, inTree, strayFiles } from '../../core/tree.ts'
|
|
11
|
-
|
|
12
|
-
import { unclassified, valued, type Secret } from '../secret.ts'
|
|
13
|
-
import { refusals, servesBrowser, type Call, type Integration, type Target } from '../target.ts'
|
|
14
|
-
import { ALGORITHM, KEY_BYTES, KEY_NAME, SEALED_AT, SEALED_NAME, VAULT_DIR } from '../vault.ts'
|
|
15
|
-
|
|
16
|
-
const TREE = findTree()
|
|
17
|
-
|
|
18
|
-
const AT = 'src/vault.ts'
|
|
19
|
-
|
|
20
|
-
const here = (one: string): string => relative(TREE.at, one).split('\\').join('/')
|
|
21
|
-
|
|
22
|
-
const from = inTree(TREE, 'secrets.ts')
|
|
23
|
-
|
|
24
|
-
const path = join(TREE.at, AT)
|
|
25
|
-
|
|
26
|
-
const declares = relative(dirname(path), from).split('\\').join('/')
|
|
27
|
-
|
|
28
|
-
const sheet = declares.startsWith('.') ? declares : `./${declares}`
|
|
29
|
-
|
|
30
|
-
function refuse(because: string): never {
|
|
31
|
-
stderr.write(`vault: ${because}\n`)
|
|
32
|
-
exit(1)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
let secrets: readonly Secret[] = []
|
|
36
|
-
try {
|
|
37
|
-
const taken = valuesIn(readFileSync(from)) as { SECRETS?: readonly Secret[] }
|
|
38
|
-
secrets = taken.SECRETS ?? []
|
|
39
|
-
} catch {
|
|
40
|
-
refuse(`${here(from)} could not be read. Attach \`${packageOf('integrations')}\` first — an app states its secrets there.`)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const declared = secrets.map((one) => one.id).sort()
|
|
44
|
-
|
|
45
|
-
const SOURCE = [
|
|
46
|
-
"import { createDecipheriv } from 'node:crypto'",
|
|
47
|
-
"import { existsSync, readFileSync } from 'node:fs'",
|
|
48
|
-
"import { join } from 'node:path'",
|
|
49
|
-
"",
|
|
50
|
-
`import { SECRETS } from '${sheet}'`,
|
|
51
|
-
"",
|
|
52
|
-
"const ENV = process.env.NODE_ENV === 'production' ? 'production' : 'sandbox'",
|
|
53
|
-
"",
|
|
54
|
-
"export async function vault(refs: readonly string[]): Promise<Readonly<Record<string, string>>> {",
|
|
55
|
-
" if (refs.length === 0) return {}",
|
|
56
|
-
` const at = join(process.cwd(), '${VAULT_DIR}', '${KEY_NAME}')`,
|
|
57
|
-
" if (!existsSync(at)) throw new Error('there is no vault key at ' + at + ', and nothing here can open a sealed value without it.')",
|
|
58
|
-
" const key = Buffer.from(readFileSync(at, 'utf8').trim(), 'base64')",
|
|
59
|
-
` if (key.length !== ${String(KEY_BYTES)}) throw new Error('the vault key at ' + at + ' is not the size anything here was sealed under.')`,
|
|
60
|
-
` const where = join(process.cwd(), '${VAULT_DIR}', '${SEALED_NAME}')`,
|
|
61
|
-
" if (!existsSync(where)) throw new Error('there is no sealed store at ' + where + ', so no declared secret has a value yet.')",
|
|
62
|
-
" const store = JSON.parse(readFileSync(where, 'utf8')) as Readonly<Record<string, Readonly<Record<string, string>> | undefined>>",
|
|
63
|
-
" const known = SECRETS.map((one) => one.id)",
|
|
64
|
-
" const out: Record<string, string> = {}",
|
|
65
|
-
" for (const named of refs) {",
|
|
66
|
-
" if (!known.includes(named)) throw new Error(named + ' is not a secret this app declares.')",
|
|
67
|
-
" const sealed = store[named]?.[ENV]",
|
|
68
|
-
" if (sealed === undefined) throw new Error(named + ' is declared and has no value sealed for ' + ENV + '.')",
|
|
69
|
-
" const parts = sealed.split('.').map((one) => Buffer.from(one, 'base64'))",
|
|
70
|
-
" const iv = parts[0]",
|
|
71
|
-
" const tag = parts[1]",
|
|
72
|
-
" const body = parts[2]",
|
|
73
|
-
" if (iv === undefined || tag === undefined || body === undefined) throw new Error(named + ' is not three base64 parts.')",
|
|
74
|
-
` const decipher = createDecipheriv('${ALGORITHM}', key, iv)`,
|
|
75
|
-
" decipher.setAuthTag(tag)",
|
|
76
|
-
" out[named] = decipher.update(body).toString('utf8') + decipher.final('utf8')",
|
|
77
|
-
" }",
|
|
78
|
-
" return out",
|
|
79
|
-
"}",
|
|
80
|
-
"",
|
|
81
|
-
].join("\n")
|
|
82
|
-
|
|
83
|
-
async function taken<T>(file: string, list: string): Promise<readonly T[]> {
|
|
84
|
-
const at = inTree(TREE, file)
|
|
85
|
-
if (!existsSync(at)) return []
|
|
86
|
-
try {
|
|
87
|
-
const held = valuesIn(readFileSync(at)) as Record<string, readonly T[] | undefined>
|
|
88
|
-
return held[list] ?? []
|
|
89
|
-
} catch {
|
|
90
|
-
return []
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function imported(at: string, text: string): readonly string[] {
|
|
95
|
-
const source = ts.createSourceFile(at, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
|
96
|
-
const found: string[] = []
|
|
97
|
-
const walk = (node: ts.Node): void => {
|
|
98
|
-
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
|
|
99
|
-
const said = node.moduleSpecifier
|
|
100
|
-
if (said !== undefined && ts.isStringLiteral(said)) found.push(said.text)
|
|
101
|
-
} else if (ts.isCallExpression(node)) {
|
|
102
|
-
const first = node.arguments[0]
|
|
103
|
-
const dynamic = node.expression.kind === ts.SyntaxKind.ImportKeyword
|
|
104
|
-
const legacy = ts.isIdentifier(node.expression) && node.expression.text === 'require'
|
|
105
|
-
if ((dynamic || legacy) && first !== undefined && ts.isStringLiteral(first)) found.push(first.text)
|
|
106
|
-
}
|
|
107
|
-
ts.forEachChild(node, walk)
|
|
108
|
-
}
|
|
109
|
-
walk(source)
|
|
110
|
-
return found
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function names(specifier: string, id: string): boolean {
|
|
114
|
-
const bare = specifier.replace(/\.(?:m|c)?[jt]sx?$/, '')
|
|
115
|
-
return bare === `operations/${id}` || bare.endsWith(`/operations/${id}`)
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
async function reaching(): Promise<readonly string[]> {
|
|
119
|
-
const targets = await taken<Target>('targets.ts', 'TARGETS')
|
|
120
|
-
const withheld = new Map<string, string>()
|
|
121
|
-
for (const one of targets) {
|
|
122
|
-
const said = servesBrowser(one, secrets)
|
|
123
|
-
if (said !== true) withheld.set(one.id, said)
|
|
124
|
-
}
|
|
125
|
-
if (withheld.size === 0) return []
|
|
126
|
-
|
|
127
|
-
const operations = await taken<{ readonly id: string; readonly requires?: readonly string[] }>('operations.ts', 'MEMBERS')
|
|
128
|
-
const risky = new Map<string, string>()
|
|
129
|
-
for (const one of operations) {
|
|
130
|
-
for (const word of one.requires ?? []) {
|
|
131
|
-
const said = withheld.get(word)
|
|
132
|
-
if (said !== undefined) risky.set(one.id, said)
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
if (risky.size === 0) return []
|
|
136
|
-
|
|
137
|
-
const found: string[] = []
|
|
138
|
-
for (const one of strayFiles(TREE.at, '', '.tsx')) {
|
|
139
|
-
let text: string
|
|
140
|
-
try {
|
|
141
|
-
text = readFileSync(join(TREE.at, one), 'utf8')
|
|
142
|
-
} catch {
|
|
143
|
-
continue
|
|
144
|
-
}
|
|
145
|
-
const specifiers = imported(one, text)
|
|
146
|
-
for (const [id, said] of risky) {
|
|
147
|
-
if (!specifiers.some((each) => names(each, id))) continue
|
|
148
|
-
found.push(
|
|
149
|
-
`${one} is a \`.tsx\` file and it imports \`${id}\`. ${said} This holds whoever bundles it — ` +
|
|
150
|
-
'a page is a page whether Next, Vite or anything else compiles it, so the refusal is about the ' +
|
|
151
|
-
'import rather than about any one build. Call it from a server and let the page ask the server',
|
|
152
|
-
)
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
return found
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
async function stated(): Promise<readonly string[]> {
|
|
159
|
-
const targets = await taken<Target>('targets.ts', 'TARGETS')
|
|
160
|
-
const integrations = await taken<Integration>('targets.ts', 'INTEGRATIONS')
|
|
161
|
-
const calls = await taken<Call>('targets.ts', 'CALLS')
|
|
162
|
-
const entities = (await taken<{ readonly id: string }>('entities.ts', 'ENTITIES')).map((one) => one.id)
|
|
163
|
-
return [
|
|
164
|
-
...unclassified(secrets),
|
|
165
|
-
...valued(secrets),
|
|
166
|
-
...refusals(targets, secrets, calls, entities, integrations),
|
|
167
|
-
...(await reaching()),
|
|
168
|
-
]
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const checking = argv.includes('--check')
|
|
172
|
-
|
|
173
|
-
const opening = `${String(declared.length)} declared secret(s)`
|
|
174
|
-
|
|
175
|
-
if (checking) {
|
|
176
|
-
const wrong = await stated()
|
|
177
|
-
if (wrong.length > 0) {
|
|
178
|
-
refuse(
|
|
179
|
-
[
|
|
180
|
-
`${String(wrong.length)} thing(s) this app states about what it reaches will not hold:`,
|
|
181
|
-
...wrong.map((one) => ` ${one}`),
|
|
182
|
-
].join('\n'),
|
|
183
|
-
)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const current = existsSync(path) ? readFileSync(path, 'utf8') : ''
|
|
187
|
-
if (current !== SOURCE) {
|
|
188
|
-
refuse(
|
|
189
|
-
current === ''
|
|
190
|
-
? `${AT} is declared and not on disk, and it is what opens this app's sealed values. Run \`thread vault\` and commit what it writes.`
|
|
191
|
-
: `${AT} is not what the secrets in ${here(from)} produce. A derived file that differs from its generator is a hand edit wearing a generated name. Run \`thread vault\` and commit what it writes.`,
|
|
192
|
-
)
|
|
193
|
-
}
|
|
194
|
-
stdout.write(`vault: ${AT} is seeded and opens ${opening}, read off ${here(from)} as they are declared.\n`)
|
|
195
|
-
exit(0)
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
if (existsSync(path) && readFileSync(path, 'utf8') === SOURCE) {
|
|
199
|
-
stdout.write(`vault: ${AT} is already yours — it opens ${opening}, read off ${here(from)} as they are declared.\n`)
|
|
200
|
-
exit(0)
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
writeFileSync(path, SOURCE, 'utf8')
|
|
204
|
-
stdout.write(
|
|
205
|
-
`vault: wrote ${AT} — what opens a secret's value at the moment of use.\n` +
|
|
206
|
-
` It opens the values sealed under ${SEALED_AT} for the secrets declared in ${here(from)}, and refuses every other name.\n` +
|
|
207
|
-
' `thread create secret` declares one and seals its value; nothing else ever takes a value.\n' +
|
|
208
|
-
' It imports nothing of this framework: what a host runs holds none of the scaffolding that assembled it.\n',
|
|
209
|
-
)
|
|
210
|
-
exit(0)
|
|
@@ -1,216 +0,0 @@
|
|
|
1
|
-
import type { Http, HttpRequest, HttpResponse } from './target.ts'
|
|
2
|
-
import { ValidationError } from '../operations/result.ts'
|
|
3
|
-
|
|
4
|
-
export const HTTP_CALL_ACTION = 'http.call'
|
|
5
|
-
|
|
6
|
-
export class HttpNotConfiguredError extends Error {
|
|
7
|
-
constructor() {
|
|
8
|
-
super(
|
|
9
|
-
'nothing here sends. An app hands its operations the transport an outward call leaves through, and this one was handed none. ' +
|
|
10
|
-
"Run thread capabilities to write the file that hands it on; which hosts may be reached is declared in the tree and is not the transport's business.",
|
|
11
|
-
)
|
|
12
|
-
this.name = 'HttpNotConfiguredError'
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const beneath = (thrown: unknown): string => {
|
|
17
|
-
const under = (thrown as { readonly cause?: { readonly code?: unknown; readonly message?: unknown } } | undefined)?.cause
|
|
18
|
-
if (under === undefined || under === null) return ''
|
|
19
|
-
return ' (' + [under.code, under.message].filter((one) => typeof one === 'string' && one !== '').join(': ') + ')'
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export class OutwardTransportError extends Error {
|
|
23
|
-
constructor(
|
|
24
|
-
readonly target: string,
|
|
25
|
-
cause: unknown,
|
|
26
|
-
) {
|
|
27
|
-
super(`outward call to "${target}" never reached it: ${cause instanceof Error ? cause.message : String(cause)}${beneath(cause)}`)
|
|
28
|
-
this.name = 'OutwardTransportError'
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export const refusedPath = (path: string): string | undefined =>
|
|
33
|
-
path.startsWith('/')
|
|
34
|
-
? `a call's path is written from its target's own address onward, and "${path}" opens with a slash — write it without one`
|
|
35
|
-
: undefined
|
|
36
|
-
|
|
37
|
-
const LOOPBACK: readonly string[] = ['localhost', '127.0.0.1', '::1']
|
|
38
|
-
|
|
39
|
-
export function outwardUrl(target: string, address: string, path: string): URL {
|
|
40
|
-
let base: URL
|
|
41
|
-
try {
|
|
42
|
-
base = new URL(address)
|
|
43
|
-
} catch {
|
|
44
|
-
throw new ValidationError(`the address resolved for "${target}" is not an absolute URL: ${JSON.stringify(address)}`)
|
|
45
|
-
}
|
|
46
|
-
if (base.protocol !== 'https:' && !(base.protocol === 'http:' && LOOPBACK.includes(base.hostname))) {
|
|
47
|
-
throw new ValidationError(
|
|
48
|
-
`outward calls go over https, and "${target}" resolved to ${base.protocol.replace(':', '')} at ${base.hostname}. ` +
|
|
49
|
-
`A server on this machine — ${LOOPBACK.join(', ')} — may answer over http, because nothing leaves the machine to reach it.`,
|
|
50
|
-
)
|
|
51
|
-
}
|
|
52
|
-
if (base.search !== '' || base.hash !== '') {
|
|
53
|
-
throw new ValidationError(`the address for "${target}" carries a query or a fragment — those belong to the call`)
|
|
54
|
-
}
|
|
55
|
-
const said = refusedPath(path)
|
|
56
|
-
if (said !== undefined) {
|
|
57
|
-
throw new ValidationError(said)
|
|
58
|
-
}
|
|
59
|
-
const url = new URL(`${base.origin}${base.pathname.replace(/\/$/, '')}/${path}`)
|
|
60
|
-
if (url.origin !== base.origin) {
|
|
61
|
-
throw new ValidationError(`the path "${path}" resolves to ${url.origin}, which is not where "${target}" is`)
|
|
62
|
-
}
|
|
63
|
-
return url
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function wireBody(body: Uint8Array): ArrayBuffer {
|
|
67
|
-
const copy = new ArrayBuffer(body.byteLength)
|
|
68
|
-
new Uint8Array(copy).set(body)
|
|
69
|
-
return copy
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export interface HttpOptions {
|
|
73
|
-
readonly timeout?: number
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const DEFAULT_TIMEOUT_MS = 10_000
|
|
77
|
-
|
|
78
|
-
export const STREAMED = 'application/x-ndjson'
|
|
79
|
-
|
|
80
|
-
export const TOLD = 'told'
|
|
81
|
-
|
|
82
|
-
async function heard(from: ReadableStream<Uint8Array>, told: (said: unknown) => void): Promise<Uint8Array> {
|
|
83
|
-
const reader = from.getReader()
|
|
84
|
-
const decoder = new TextDecoder()
|
|
85
|
-
let pending = ''
|
|
86
|
-
let last = ''
|
|
87
|
-
const line = (one: string): void => {
|
|
88
|
-
if (one.trim() === '') return
|
|
89
|
-
const held = JSON.parse(one) as Record<string, unknown>
|
|
90
|
-
if (typeof held === 'object' && held !== null && TOLD in held) told(held[TOLD])
|
|
91
|
-
else last = one
|
|
92
|
-
}
|
|
93
|
-
for (;;) {
|
|
94
|
-
const { done, value } = await reader.read()
|
|
95
|
-
if (done) break
|
|
96
|
-
pending += decoder.decode(value, { stream: true })
|
|
97
|
-
let at = pending.indexOf('\n')
|
|
98
|
-
while (at !== -1) {
|
|
99
|
-
line(pending.slice(0, at))
|
|
100
|
-
pending = pending.slice(at + 1)
|
|
101
|
-
at = pending.indexOf('\n')
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
line(pending + decoder.decode())
|
|
105
|
-
return new TextEncoder().encode(last)
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export async function called(request: HttpRequest, options: HttpOptions = {}): Promise<HttpResponse> {
|
|
109
|
-
const send = fetch
|
|
110
|
-
const within = options.timeout ?? DEFAULT_TIMEOUT_MS
|
|
111
|
-
|
|
112
|
-
const url = outwardUrl(request.target, request.address, request.path)
|
|
113
|
-
|
|
114
|
-
const headers: Record<string, string> = { ...request.headers }
|
|
115
|
-
if (request.contentType !== undefined) headers['content-type'] = request.contentType
|
|
116
|
-
if (request.idempotencyKey !== undefined) headers['idempotency-key'] = request.idempotencyKey
|
|
117
|
-
if (request.told !== undefined) headers['accept'] = STREAMED
|
|
118
|
-
|
|
119
|
-
let response: Response
|
|
120
|
-
try {
|
|
121
|
-
response = await send(url.toString(), {
|
|
122
|
-
method: request.method,
|
|
123
|
-
headers,
|
|
124
|
-
...(request.body === undefined ? {} : { body: wireBody(request.body) }),
|
|
125
|
-
signal: AbortSignal.timeout(within),
|
|
126
|
-
})
|
|
127
|
-
} catch (error) {
|
|
128
|
-
throw new OutwardTransportError(request.target, error)
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const told = request.told
|
|
132
|
-
const stream = response.body
|
|
133
|
-
const streamed = (response.headers.get('content-type') ?? '').includes(STREAMED)
|
|
134
|
-
let body: Uint8Array
|
|
135
|
-
try {
|
|
136
|
-
body = told !== undefined && stream !== null && streamed ? await heard(stream, told) : new Uint8Array(await response.arrayBuffer())
|
|
137
|
-
} catch (error) {
|
|
138
|
-
throw new OutwardTransportError(request.target, error)
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
return {
|
|
142
|
-
status: response.status,
|
|
143
|
-
headers: Object.fromEntries(response.headers.entries()),
|
|
144
|
-
body,
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
export function sendingHttp(options: HttpOptions = {}): Http {
|
|
149
|
-
return { call: (request, within) => called(request, within === undefined ? options : { ...options, timeout: within }) }
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
export interface RecordedCall {
|
|
153
|
-
readonly target: string
|
|
154
|
-
readonly method: string
|
|
155
|
-
readonly path: string
|
|
156
|
-
readonly headers: Readonly<Record<string, string>>
|
|
157
|
-
readonly body: string
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
export interface ScriptedAnswer {
|
|
161
|
-
readonly status: number
|
|
162
|
-
readonly headers?: Readonly<Record<string, string>>
|
|
163
|
-
readonly body?: string
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const REDACTED = '(sent)'
|
|
167
|
-
|
|
168
|
-
export type Scripted = ScriptedAnswer | readonly ScriptedAnswer[]
|
|
169
|
-
|
|
170
|
-
export function recordingHttp(
|
|
171
|
-
script: Readonly<Record<string, Scripted>> = {},
|
|
172
|
-
): Http & { readonly calls: readonly RecordedCall[] } {
|
|
173
|
-
const calls: RecordedCall[] = []
|
|
174
|
-
const decoder = new TextDecoder()
|
|
175
|
-
const spent = new Map<string, number>()
|
|
176
|
-
|
|
177
|
-
return {
|
|
178
|
-
calls,
|
|
179
|
-
async call(request) {
|
|
180
|
-
outwardUrl(request.target, request.address, request.path)
|
|
181
|
-
|
|
182
|
-
const body = request.body === undefined ? '' : decoder.decode(request.body)
|
|
183
|
-
calls.push({
|
|
184
|
-
target: request.target,
|
|
185
|
-
method: request.method,
|
|
186
|
-
path: request.path,
|
|
187
|
-
headers: Object.fromEntries(Object.keys(request.headers ?? {}).map((one) => [one, REDACTED])),
|
|
188
|
-
body,
|
|
189
|
-
})
|
|
190
|
-
|
|
191
|
-
const key = `${request.method} ${request.path}`
|
|
192
|
-
const held = script[key]
|
|
193
|
-
if (held === undefined) {
|
|
194
|
-
throw new Error(
|
|
195
|
-
`this scenario scripts no answer for "${key}", so nothing here knows what ${request.target} would say. Add that key to the scenario's answers — a call answered by something nobody scripted proves it was made and nothing about what came back.`,
|
|
196
|
-
)
|
|
197
|
-
}
|
|
198
|
-
const each = Array.isArray(held) ? (held as readonly ScriptedAnswer[]) : undefined
|
|
199
|
-
const at = spent.get(key) ?? 0
|
|
200
|
-
spent.set(key, at + 1)
|
|
201
|
-
const answer = each === undefined ? (held as ScriptedAnswer) : each[at]
|
|
202
|
-
if (answer === undefined) {
|
|
203
|
-
throw new Error(
|
|
204
|
-
`this scenario scripts ${String(each?.length ?? 0)} answer(s) for "${key}" and it has now been called ${String(at + 1)} time(s). A node that calls the same thing again needs one answer per pass, written in the order they come back.`,
|
|
205
|
-
)
|
|
206
|
-
}
|
|
207
|
-
const answered = new TextEncoder().encode(answer.body ?? '')
|
|
208
|
-
const headers = answer.headers ?? {}
|
|
209
|
-
return {
|
|
210
|
-
status: answer.status,
|
|
211
|
-
headers,
|
|
212
|
-
body: answered,
|
|
213
|
-
}
|
|
214
|
-
},
|
|
215
|
-
}
|
|
216
|
-
}
|
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
import type { Actor } from '../core/actor.ts'
|
|
2
|
-
import type { FieldList, Row, Store } from './target.ts'
|
|
3
|
-
|
|
4
|
-
const SHAPE = `
|
|
5
|
-
select table_name, column_name, data_type, is_nullable
|
|
6
|
-
from information_schema.columns
|
|
7
|
-
where table_schema = 'public'
|
|
8
|
-
order by table_name, column_name
|
|
9
|
-
`
|
|
10
|
-
|
|
11
|
-
const CONSTRAINTS = `
|
|
12
|
-
select tc.table_name, tc.constraint_type, kcu.column_name
|
|
13
|
-
from information_schema.table_constraints tc
|
|
14
|
-
left join information_schema.key_column_usage kcu on kcu.constraint_name = tc.constraint_name
|
|
15
|
-
where tc.table_schema = 'public'
|
|
16
|
-
order by tc.table_name, tc.constraint_type, kcu.column_name
|
|
17
|
-
`
|
|
18
|
-
|
|
19
|
-
interface Queried {
|
|
20
|
-
exec(sql: string): Promise<unknown>
|
|
21
|
-
query(sql: string, params?: readonly unknown[]): Promise<{ rows: readonly unknown[] }>
|
|
22
|
-
close(): Promise<void>
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const MISSING =
|
|
26
|
-
'a postgres living inside this process needs `@electric-sql/pglite`, and it is not installed. No package here ' +
|
|
27
|
-
'declares it, because nothing needs one until a target is declared that keeps rows in one — install it in the ' +
|
|
28
|
-
'app that declares that target. Carrying on without having opened one would be the drift this exists to catch, ' +
|
|
29
|
-
'wearing a green tick'
|
|
30
|
-
|
|
31
|
-
async function opened(at?: string): Promise<Queried> {
|
|
32
|
-
let made: { PGlite: new (at?: string) => Queried }
|
|
33
|
-
try {
|
|
34
|
-
made = (await import('@electric-sql/pglite')) as unknown as { PGlite: new (at?: string) => Queried }
|
|
35
|
-
} catch {
|
|
36
|
-
throw new Error(MISSING)
|
|
37
|
-
}
|
|
38
|
-
return new made.PGlite(at)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const APPLIED = '"_thread_applied"'
|
|
42
|
-
|
|
43
|
-
async function caughtUp(made: Queried, history: readonly { readonly name: string; readonly sql: string }[]): Promise<void> {
|
|
44
|
-
if (history.length === 0) return
|
|
45
|
-
await made.exec('create table if not exists ' + APPLIED + ' ("name" text primary key)')
|
|
46
|
-
const done = new Set(
|
|
47
|
-
(await made.query('select "name" from ' + APPLIED)).rows.map((one) => String((one as { readonly name?: unknown }).name)),
|
|
48
|
-
)
|
|
49
|
-
for (const one of history) {
|
|
50
|
-
if (done.has(one.name)) continue
|
|
51
|
-
await made.exec(one.sql)
|
|
52
|
-
await made.query('insert into ' + APPLIED + ' ("name") values ($1)', [one.name])
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export async function shapeOf(sql: readonly string[]): Promise<string> {
|
|
57
|
-
const db = await opened()
|
|
58
|
-
try {
|
|
59
|
-
for (const one of sql) await db.exec(one)
|
|
60
|
-
const columns = await db.query(SHAPE)
|
|
61
|
-
const constraints = await db.query(CONSTRAINTS)
|
|
62
|
-
return JSON.stringify({ columns: columns.rows, constraints: constraints.rows }, null, 2)
|
|
63
|
-
} finally {
|
|
64
|
-
await db.close()
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export interface Kept {
|
|
69
|
-
|
|
70
|
-
readonly name: string
|
|
71
|
-
|
|
72
|
-
readonly schema: string
|
|
73
|
-
|
|
74
|
-
readonly tables: Readonly<Record<string, string>>
|
|
75
|
-
|
|
76
|
-
readonly address: Readonly<Record<string, string>>
|
|
77
|
-
|
|
78
|
-
readonly columns: Readonly<Record<string, readonly string[]>>
|
|
79
|
-
|
|
80
|
-
readonly at: string
|
|
81
|
-
|
|
82
|
-
readonly migrations?: readonly { readonly name: string; readonly sql: string }[]
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export interface Stood {
|
|
86
|
-
|
|
87
|
-
readonly keeps: readonly string[]
|
|
88
|
-
|
|
89
|
-
readonly store: Store
|
|
90
|
-
|
|
91
|
-
seed(actor: Actor, rows: readonly { readonly entity: string; readonly row: Row }[]): Promise<void>
|
|
92
|
-
|
|
93
|
-
taken(actor: Actor): Promise<Readonly<Record<string, readonly Row[]>>>
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export function standing(held: Omit<Kept, 'at'>): Stood {
|
|
97
|
-
const store = storing({ ...held, at: 'memory://', migrations: [] })
|
|
98
|
-
const keeps = Object.keys(held.tables)
|
|
99
|
-
return {
|
|
100
|
-
keeps,
|
|
101
|
-
store,
|
|
102
|
-
async seed(actor, rows) {
|
|
103
|
-
for (const one of rows) await store.insert(actor, one.entity, one.row)
|
|
104
|
-
},
|
|
105
|
-
async taken(actor) {
|
|
106
|
-
const found: Record<string, readonly Row[]> = {}
|
|
107
|
-
for (const entity of keeps) found[entity] = await store.list(actor, entity)
|
|
108
|
-
return found
|
|
109
|
-
},
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function storing(held: Kept): Store {
|
|
114
|
-
const q = (name: string): string => JSON.stringify(name)
|
|
115
|
-
const rowOf = (one: unknown): Row => one as Row
|
|
116
|
-
const known = (entity: string): readonly string[] => held.columns[entity] ?? []
|
|
117
|
-
|
|
118
|
-
const tableFor = (entity: string): string => {
|
|
119
|
-
const found = held.tables[entity]
|
|
120
|
-
if (found === undefined) throw new Error(entity + ' is not kept in ' + held.name + ', so it has no table here')
|
|
121
|
-
return found
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const addressOf = (entity: string): string => {
|
|
125
|
-
const found = held.address[entity]
|
|
126
|
-
if (found === undefined) {
|
|
127
|
-
throw new Error(
|
|
128
|
-
entity + ' declares nothing that identifies one of its rows, so a row of it cannot be addressed here. ' +
|
|
129
|
-
'Give it a value and say so with `thread create entity edit`.',
|
|
130
|
-
)
|
|
131
|
-
}
|
|
132
|
-
return found
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
let opening: Promise<Queried> | undefined
|
|
136
|
-
|
|
137
|
-
const db = async (): Promise<Queried> => {
|
|
138
|
-
opening ??= (async () => {
|
|
139
|
-
const made = await opened(held.at)
|
|
140
|
-
await caughtUp(made, held.migrations ?? [])
|
|
141
|
-
await made.exec(held.schema)
|
|
142
|
-
return made
|
|
143
|
-
})()
|
|
144
|
-
return await opening
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
return {
|
|
148
|
-
async insert(_actor, entity, row) {
|
|
149
|
-
const cols = known(entity)
|
|
150
|
-
const taken = cols.filter((one) => (row as Record<string, unknown>)[one] !== undefined)
|
|
151
|
-
const answered = await (await db()).query(
|
|
152
|
-
'insert into ' + q(tableFor(entity)) + ' (' + taken.map(q).join(', ') + ') values (' +
|
|
153
|
-
taken.map((_, at) => '$' + String(at + 1)).join(', ') + ') returning *',
|
|
154
|
-
taken.map((one) => (row as Record<string, unknown>)[one]),
|
|
155
|
-
)
|
|
156
|
-
return rowOf(answered.rows[0])
|
|
157
|
-
},
|
|
158
|
-
|
|
159
|
-
async get(_actor, entity, key, fields: FieldList) {
|
|
160
|
-
const cols = known(entity)
|
|
161
|
-
const asked = fields.filter((one) => cols.includes(one as string)).map((one) => String(one))
|
|
162
|
-
const answered = await (await db()).query(
|
|
163
|
-
'select ' + (asked.length === 0 ? '*' : asked.map(q).join(', ')) + ' from ' + q(tableFor(entity)) +
|
|
164
|
-
' where ' + q(addressOf(entity)) + ' = $1 limit 1',
|
|
165
|
-
[key],
|
|
166
|
-
)
|
|
167
|
-
return answered.rows.length === 0 ? null : rowOf(answered.rows[0])
|
|
168
|
-
},
|
|
169
|
-
|
|
170
|
-
async list(_actor, entity, opts) {
|
|
171
|
-
const cols = known(entity)
|
|
172
|
-
const asked = Object.entries(opts?.where ?? {}).filter(([one]) => cols.includes(one))
|
|
173
|
-
const answered = await (await db()).query(
|
|
174
|
-
'select * from ' + q(tableFor(entity)) +
|
|
175
|
-
(asked.length === 0 ? '' : ' where ' + asked.map(([one], at) => q(one) + ' = $' + String(at + 1)).join(' and ')) +
|
|
176
|
-
(opts?.limit === undefined ? '' : ' limit ' + String(opts.limit)),
|
|
177
|
-
asked.map(([, one]) => one),
|
|
178
|
-
)
|
|
179
|
-
return answered.rows.map(rowOf)
|
|
180
|
-
},
|
|
181
|
-
|
|
182
|
-
async update(_actor, entity, key, patch, expectedVersion) {
|
|
183
|
-
if (expectedVersion !== undefined) {
|
|
184
|
-
throw new Error(
|
|
185
|
-
held.name + ' keeps what ' + entity + ' declares and nothing else, so there is no version on a row to ' +
|
|
186
|
-
'check an expected one against. Declare a value for it and compare that, or update without a version.',
|
|
187
|
-
)
|
|
188
|
-
}
|
|
189
|
-
const cols = known(entity)
|
|
190
|
-
const taken = Object.entries(patch).filter(([one]) => cols.includes(one))
|
|
191
|
-
if (taken.length === 0) throw new Error('nothing in this patch is a value ' + entity + ' declares')
|
|
192
|
-
const answered = await (await db()).query(
|
|
193
|
-
'update ' + q(tableFor(entity)) + ' set ' +
|
|
194
|
-
taken.map(([one], at) => q(one) + ' = $' + String(at + 1)).join(', ') +
|
|
195
|
-
' where ' + q(addressOf(entity)) + ' = $' + String(taken.length + 1) + ' returning *',
|
|
196
|
-
[...taken.map(([, one]) => one), key],
|
|
197
|
-
)
|
|
198
|
-
if (answered.rows.length === 0) throw new Error('record ' + String(key) + ' not found')
|
|
199
|
-
return rowOf(answered.rows[0])
|
|
200
|
-
},
|
|
201
|
-
|
|
202
|
-
async remove(_actor, entity, key) {
|
|
203
|
-
await (await db()).query('delete from ' + q(tableFor(entity)) + ' where ' + q(addressOf(entity)) + ' = $1', [key])
|
|
204
|
-
},
|
|
205
|
-
}
|
|
206
|
-
}
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import type { Member } from '../core/thread.ts'
|
|
2
|
-
|
|
3
|
-
export const SECRECY = {
|
|
4
|
-
server: 'sec_',
|
|
5
|
-
} as const satisfies Readonly<Record<string, string>>
|
|
6
|
-
|
|
7
|
-
export const SAID: Readonly<Record<keyof typeof SECRECY, string>> = {
|
|
8
|
-
server:
|
|
9
|
-
'A value only a server may ever hold: an API key, a connection string, a signing key. Every secret is one of these. A value anybody may read is not a secret and is never sealed — the address of a place this app calls and a header every call carries are written on the target row, and only a credential goes into the vault. Everything a browser bundle reaches is in the bundle and belongs to whoever opens the page, so a target requiring a secret is refused a browser entry when it is built rather than when somebody reads it.',
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface Vault {
|
|
13
|
-
|
|
14
|
-
resolve(refs: readonly string[]): Promise<Readonly<Record<string, string>>>
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface Secret extends Member {
|
|
18
|
-
|
|
19
|
-
readonly sends?: { readonly header: string; readonly prefix?: string }
|
|
20
|
-
|
|
21
|
-
readonly signs?: { readonly header: string; readonly prefix?: string }
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function secrecyOf(id: string): keyof typeof SECRECY | undefined {
|
|
25
|
-
return (Object.keys(SECRECY) as (keyof typeof SECRECY)[]).find((one) => id.startsWith(SECRECY[one]))
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export const isServerOnly = (id: string): boolean => secrecyOf(id) === 'server'
|
|
29
|
-
|
|
30
|
-
export const unclassified = (secrets: readonly Secret[]): readonly string[] =>
|
|
31
|
-
secrets
|
|
32
|
-
.filter((one) => secrecyOf(one.id) === undefined)
|
|
33
|
-
.map(
|
|
34
|
-
(one) =>
|
|
35
|
-
one.id + ' does not wear ' + SECRECY.server + ', and every secret does: that prefix says only a server may ever hold the value. ' +
|
|
36
|
-
'A value anybody may read is not a secret. Write it on the row that needs it, as the address of a target or a header every call carries, and take this row out',
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
const VALUED = ['value', 'secret', 'key', 'token', 'password', 'env']
|
|
40
|
-
|
|
41
|
-
export const valued = (secrets: readonly Secret[]): readonly string[] => {
|
|
42
|
-
const found: string[] = []
|
|
43
|
-
for (const one of secrets) {
|
|
44
|
-
for (const named of VALUED) {
|
|
45
|
-
if (!(named in one)) continue
|
|
46
|
-
found.push(
|
|
47
|
-
`\`${one.id}\` carries a \`${named}\`. A secret row holds a name and a sentence and never a value — this row is ` +
|
|
48
|
-
'read by the descent, by every generator and by anything that prints the tree, while a credential is read by ' +
|
|
49
|
-
'one thing at one moment. Put the value in the sealed store with `thread create secret`',
|
|
50
|
-
)
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return found
|
|
54
|
-
}
|