@prestack/thread 0.0.1 → 0.0.3
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/dist/core/actor.ts +20 -0
- package/dist/core/asking.d.ts +0 -1
- package/dist/core/asking.js +0 -1
- package/dist/core/asking.ts +1342 -0
- package/dist/core/attach.js +2 -2
- package/dist/core/attach.ts +585 -0
- package/dist/core/chain.js +8 -1
- package/dist/core/chain.ts +148 -0
- package/dist/core/cli.js +97 -18
- package/dist/core/cli.ts +2004 -0
- package/dist/core/condition.ts +227 -0
- package/dist/core/descend.d.ts +1 -0
- package/dist/core/descend.js +36 -0
- package/dist/core/descend.ts +1001 -0
- package/dist/core/finding.ts +38 -0
- package/dist/core/generators/meta.js +6 -0
- package/dist/core/generators/meta.ts +1006 -0
- package/dist/core/init.ts +170 -0
- package/dist/core/invoked.ts +47 -0
- package/dist/core/round.ts +57 -0
- package/dist/core/rows.ts +369 -0
- package/dist/core/scope.ts +18 -0
- package/dist/core/thread.ts +29 -0
- package/dist/core/tree.ts +67 -0
- package/dist/core/types.ts +350 -0
- package/dist/derivation/attach.js +1 -1
- package/dist/derivation/attach.ts +68 -0
- package/dist/derivation/generators/build.ts +232 -0
- package/dist/derivation/generators/check.ts +118 -0
- package/dist/derivation/generators/derivation.ts +347 -0
- package/dist/derivation/generators/determinism.ts +145 -0
- package/dist/derivation/generators/release.js +19 -9
- package/dist/derivation/generators/release.ts +213 -0
- package/dist/derivation/makers/release.ts +135 -0
- package/dist/derivation/release.ts +23 -0
- package/dist/integrations/attach.js +2 -2
- package/dist/integrations/attach.ts +207 -0
- package/dist/integrations/generators/read.ts +127 -0
- package/dist/integrations/generators/secret.ts +289 -0
- package/dist/integrations/generators/target.js +16 -6
- package/dist/integrations/generators/target.ts +944 -0
- package/dist/integrations/generators/vault.js +46 -14
- package/dist/integrations/generators/vault.ts +212 -0
- package/dist/integrations/{sdk.js → module.js} +4 -4
- package/dist/integrations/module.ts +189 -0
- package/dist/integrations/secret.ts +50 -0
- package/dist/integrations/target.d.ts +3 -1
- package/dist/integrations/target.js +14 -11
- package/dist/integrations/target.ts +340 -0
- package/dist/integrations/vault.d.ts +2 -0
- package/dist/integrations/vault.js +4 -2
- package/dist/integrations/vault.ts +136 -0
- package/dist/integrity/attach.ts +234 -0
- package/dist/integrity/check-threads.js +2 -26
- package/dist/integrity/check-threads.ts +166 -0
- package/dist/integrity/constraint.ts +1202 -0
- package/dist/integrity/generators/compiler.ts +161 -0
- package/dist/integrity/generators/gate.ts +201 -0
- package/dist/integrity/generators/issues.ts +160 -0
- package/dist/integrity/generators/publish.js +14 -5
- package/dist/integrity/generators/publish.ts +247 -0
- package/dist/integrity/generators/rule.ts +271 -0
- package/dist/integrity/generators/seal.ts +117 -0
- package/dist/integrity/generators/trial.ts +320 -0
- package/dist/integrity/lint.ts +227 -0
- package/dist/integrity/lsp/bin.ts +42 -0
- package/dist/integrity/lsp/diagnostics.ts +92 -0
- package/dist/integrity/lsp/findings.ts +63 -0
- package/dist/integrity/lsp/lint.ts +140 -0
- package/dist/integrity/lsp/protocol.ts +78 -0
- package/dist/integrity/lsp/server.ts +224 -0
- package/dist/integrity/sealed.js +2 -2
- package/dist/integrity/sealed.ts +56 -0
- package/dist/integrity/slopnator.ts +350 -0
- package/dist/integrity/slopnators/sl_broken_lineage.ts +36 -0
- package/dist/integrity/slopnators/sl_declared_elsewhere.ts +39 -0
- package/dist/integrity/slopnators/sl_derived_drift.ts +98 -0
- package/dist/integrity/slopnators/sl_hardcoded_style.ts +55 -0
- package/dist/integrity/slopnators/sl_inert_island.ts +91 -0
- package/dist/integrity/slopnators/sl_inert_slopnator.ts +93 -0
- package/dist/integrity/slopnators/sl_missing_row.ts +182 -0
- package/dist/integrity/slopnators/sl_no_barrel.ts +48 -0
- package/dist/integrity/slopnators/sl_no_child_process.ts +74 -0
- package/dist/integrity/slopnators/sl_no_comment.ts +43 -0
- package/dist/integrity/slopnators/sl_no_dispatch_bypass.ts +328 -0
- package/dist/integrity/slopnators/sl_no_import_cycle.ts +50 -0
- package/dist/integrity/slopnators/sl_no_such_command.ts +64 -0
- package/dist/integrity/slopnators/sl_only_takes_a_literal.ts +63 -0
- package/dist/integrity/slopnators/sl_secret_literal.ts +100 -0
- package/dist/integrity/slopnators/sl_stale_path.ts +94 -0
- package/dist/integrity/slopnators/sl_twice_meant.ts +31 -0
- package/dist/integrity/slopnators/sl_twice_parented.ts +37 -0
- package/dist/integrity/slopnators/sl_undeclared_crossing.ts +87 -0
- package/dist/integrity/slopnators/sl_undeclared_destination.ts +65 -0
- package/dist/integrity/slopnators/sl_undeclared_element.ts +44 -0
- package/dist/integrity/slopnators/sl_undeclared_node.ts +69 -0
- package/dist/integrity/slopnators/sl_undeclared_operation.ts +147 -0
- package/dist/integrity/slopnators/sl_undeclared_variant.ts +148 -0
- package/dist/integrity/slopnators/sl_undescribed.ts +65 -0
- package/dist/integrity/slopnators/sl_ungoverned_island.ts +61 -0
- package/dist/integrity/slopnators/sl_unkept_rows.ts +111 -0
- package/dist/integrity/slopnators/sl_unnamed_island.ts +41 -0
- package/dist/integrity/slopnators/sl_unpaired_member.ts +131 -0
- package/dist/integrity/slopnators/sl_unpaired_slopnator.ts +58 -0
- package/dist/integrity/slopnators/sl_unprefixed_member.ts +136 -0
- package/dist/integrity/slopnators/sl_unseamed_reach.ts +79 -0
- package/dist/integrity/slopnators/sl_unthreaded.ts +419 -0
- package/dist/integrity/slopnators/sl_untranslated_key.ts +164 -0
- package/dist/integrity/slopnators/sl_unversioned.ts +36 -0
- package/dist/integrity/sweep.ts +307 -0
- package/dist/knowledge/attach.ts +350 -0
- package/dist/knowledge/generators/docsgen.ts +174 -0
- package/dist/knowledge/generators/glossary.ts +73 -0
- package/dist/knowledge/generators/skills.ts +263 -0
- package/dist/knowledge/glossary.ts +6 -0
- package/dist/operations/access.ts +88 -0
- package/dist/operations/attach.ts +475 -0
- package/dist/operations/capabilities.ts +65 -0
- package/dist/operations/cost.ts +58 -0
- package/dist/operations/dispatch.ts +348 -0
- package/dist/operations/error-results.ts +31 -0
- package/dist/operations/errors/catalog.ts +168 -0
- package/dist/operations/errors/classes.ts +51 -0
- package/dist/operations/errors/errors.ts +314 -0
- package/dist/operations/generators/capability.js +29 -19
- package/dist/operations/generators/capability.ts +704 -0
- package/dist/operations/generators/compile.js +20 -1
- package/dist/operations/generators/compile.ts +643 -0
- package/dist/operations/generators/grant.ts +336 -0
- package/dist/operations/generators/operation.ts +972 -0
- package/dist/operations/generators/permits.ts +223 -0
- package/dist/operations/generators/seed.ts +347 -0
- package/dist/operations/generators/trial.js +4 -1
- package/dist/operations/generators/trial.ts +529 -0
- package/dist/operations/http.ts +164 -0
- package/dist/operations/makers/capability.ts +166 -0
- package/dist/operations/makers/trial.ts +520 -0
- package/dist/operations/memory.ts +470 -0
- package/dist/operations/metering.ts +328 -0
- package/dist/operations/nodes.ts +54 -0
- package/dist/operations/operations.ts +32 -0
- package/dist/operations/queue-policy.ts +10 -0
- package/dist/operations/result.ts +28 -0
- package/dist/operations/seams.d.ts +4 -4
- package/dist/operations/seams.ts +364 -0
- package/dist/operations/store.ts +29 -0
- package/package.json +8 -1
- package/dist/integrations/generators/sdk.d.ts +0 -1
- package/dist/integrations/generators/sdk.js +0 -148
- /package/dist/integrations/{sdk.d.ts → module.d.ts} +0 -0
|
@@ -4,23 +4,23 @@ export const REACH = {
|
|
|
4
4
|
pglite: 'pl_',
|
|
5
5
|
browser: 'zu_',
|
|
6
6
|
https: 'ht_',
|
|
7
|
-
|
|
7
|
+
module: 'mod_',
|
|
8
8
|
cli: 'cli_',
|
|
9
9
|
};
|
|
10
10
|
export const HOLDS = {
|
|
11
|
-
postgres: { takes: 'entities', from: 'server', addressed: true, arrives: null, migrations: true, references: true, sql: true },
|
|
12
|
-
pglite: { takes: 'entities', from: 'either', addressed: true, arrives: null, migrations: true, references: true, sql: true },
|
|
13
|
-
browser: { takes: 'entities', from: 'browser', addressed: false, arrives: null, migrations: false, references: false, sql: false },
|
|
14
|
-
https: { takes: 'calls', from: 'either', addressed: true, arrives: null, migrations: false, references: false, sql: false },
|
|
15
|
-
|
|
16
|
-
cli: { takes: 'names', from: 'server', addressed: false, arrives: 'machine', migrations: false, references: false, sql: false },
|
|
11
|
+
postgres: { takes: 'entities', from: 'server', addressed: true, arrives: null, migrations: true, references: true, sql: true, driver: 'postgres' },
|
|
12
|
+
pglite: { takes: 'entities', from: 'either', addressed: true, arrives: null, migrations: true, references: true, sql: true, driver: '@electric-sql/pglite' },
|
|
13
|
+
browser: { takes: 'entities', from: 'browser', addressed: false, arrives: null, migrations: false, references: false, sql: false, driver: 'zustand' },
|
|
14
|
+
https: { takes: 'calls', from: 'either', addressed: true, arrives: null, migrations: false, references: false, sql: false, driver: null },
|
|
15
|
+
module: { takes: 'names', from: 'either', addressed: false, arrives: 'npm', migrations: false, references: false, sql: false, driver: null },
|
|
16
|
+
cli: { takes: 'names', from: 'server', addressed: false, arrives: 'machine', migrations: false, references: false, sql: false, driver: null },
|
|
17
17
|
};
|
|
18
18
|
export const SAID = {
|
|
19
19
|
postgres: 'A postgres this app connects to over the network. It keeps entities and holds everything a declaration can state about their shape — tables, references and invariants — so what is kept here is held to that shape by the database itself rather than by whatever remembered to check. Who may touch a row is not asked of it: that is answered by the app, against the caller who actually arrived. It is reached from a server only: a connection to it from a page is a database somebody else can open. This is where a thing goes when losing it would matter.',
|
|
20
20
|
pglite: 'A postgres running inside the process that opened it. It keeps entities and holds tables, references and invariants, exactly as the networked one does — what differs is who can reach it, not what it can be told to hold. What belongs here is what the caller is already entitled to see: a local copy, a working set, an offline cache.',
|
|
21
21
|
browser: 'A store in the browser, held in memory the viewer owns. It keeps entities and it is addressed by nothing, because it is already where the code runs. It holds no migration, because a page load builds it from nothing every time. What belongs here is what the interface needs to be responsive and can afford to lose.',
|
|
22
22
|
https: 'Something this app calls over https — a vendor, a service, another app. It accepts calls rather than entities: each is declared with the verb and the path it takes, so a node names the call and never writes a url. Its address and its credential are both secrets, and whether it may be reached from a browser is decided by which kind of secret it needs rather than by the reach itself.',
|
|
23
|
-
|
|
23
|
+
module: 'Something this app brings in from outside the repository and calls — an npm package, an SDK, an installer. Declaring one installs it and reads the methods it publishes, then asks which of them this app may call. What is picked is written down, and a method that is not on that list is refused before the package is imported: what can ever run is fixed when the target is declared, not by whoever calls it. A credential is named only if it needs one, and it comes from the vault at the moment of the call and is never written anywhere. A package you only import is a dependency rather than one of these, and the manifest already declares it.',
|
|
24
24
|
cli: 'A program this app runs as a command — git is the one that made this necessary. It is not installed, because it is not a dependency: it is either on the machine or it is not, and declaring one checks that it is there and says what to do when it is not. What it accepts is its subcommands, read off the program itself at the moment it is declared and written down: the whole surface the tool really has, because it was asked rather than typed, and a subcommand that is not on that list never runs. Declaring one is what brings `thread run <name>` into being, so the CLI grows a command by the app stating an integration rather than by anybody writing a verb — and an app that has not declared it has no such command at all.',
|
|
25
25
|
};
|
|
26
26
|
export const CALL_PREFIX = 'cl_';
|
|
@@ -33,6 +33,9 @@ export function holdsOf(id) {
|
|
|
33
33
|
const what = reachOf(id);
|
|
34
34
|
return what === undefined ? undefined : HOLDS[what];
|
|
35
35
|
}
|
|
36
|
+
export function driverOf(id) {
|
|
37
|
+
return holdsOf(id)?.driver ?? null;
|
|
38
|
+
}
|
|
36
39
|
export function addressOf(one, secrets) {
|
|
37
40
|
if (holdsOf(one.id)?.addressed !== true)
|
|
38
41
|
return undefined;
|
|
@@ -71,20 +74,20 @@ export const unreachable = (targets) => targets
|
|
|
71
74
|
.map((one) => `\`${one.id}\` wears none of the prefixes a target is reached by (${Object.values(REACH).join(', ')}), so ` +
|
|
72
75
|
'nothing can tell how to reach it — and how a thing is reached decides what may be declared for it');
|
|
73
76
|
const SHAPED = {
|
|
74
|
-
|
|
77
|
+
module: /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/,
|
|
75
78
|
cli: /^[a-z][a-z0-9-]*$/,
|
|
76
79
|
};
|
|
77
80
|
const EVERY_VALUE = new Set(['constructor', '__proto__', 'prototype']);
|
|
78
81
|
export function misnamed(target, name) {
|
|
79
82
|
const reach = reachOf(target);
|
|
80
|
-
if (reach !== '
|
|
83
|
+
if (reach !== 'module' && reach !== 'cli')
|
|
81
84
|
return undefined;
|
|
82
85
|
if (EVERY_VALUE.has(name)) {
|
|
83
86
|
return (`\`${name}\` is a step every JavaScript value has rather than something \`${target}\` offers, and walking to ` +
|
|
84
87
|
'it leaves what was declared');
|
|
85
88
|
}
|
|
86
89
|
if (!SHAPED[reach].test(name)) {
|
|
87
|
-
return reach === '
|
|
90
|
+
return reach === 'module'
|
|
88
91
|
? `\`${name}\` is not a method path — one name, or names joined by dots, e.g. \`customers.list\``
|
|
89
92
|
: `\`${name}\` is not a subcommand — one word as the program spells it, e.g. \`commit\` or \`rev-parse\``;
|
|
90
93
|
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import type { Member } from '../core/thread.ts'
|
|
2
|
+
|
|
3
|
+
import { SECRECY, secrecyOf, type Secret } from './secret.ts'
|
|
4
|
+
|
|
5
|
+
export const REACH = {
|
|
6
|
+
postgres: 'pg_',
|
|
7
|
+
pglite: 'pl_',
|
|
8
|
+
browser: 'zu_',
|
|
9
|
+
https: 'ht_',
|
|
10
|
+
module: 'mod_',
|
|
11
|
+
cli: 'cli_',
|
|
12
|
+
} as const satisfies Readonly<Record<string, string>>
|
|
13
|
+
|
|
14
|
+
export type Reach = keyof typeof REACH
|
|
15
|
+
|
|
16
|
+
export interface Holds {
|
|
17
|
+
|
|
18
|
+
readonly takes: 'entities' | 'calls' | 'names'
|
|
19
|
+
|
|
20
|
+
readonly from: 'server' | 'browser' | 'either'
|
|
21
|
+
|
|
22
|
+
readonly addressed: boolean
|
|
23
|
+
|
|
24
|
+
readonly arrives: 'npm' | 'machine' | null
|
|
25
|
+
|
|
26
|
+
readonly migrations: boolean
|
|
27
|
+
|
|
28
|
+
readonly references: boolean
|
|
29
|
+
|
|
30
|
+
readonly sql: boolean
|
|
31
|
+
|
|
32
|
+
readonly driver: string | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const HOLDS: Readonly<Record<Reach, Holds>> = {
|
|
36
|
+
postgres: { takes: 'entities', from: 'server', addressed: true, arrives: null, migrations: true, references: true, sql: true, driver: 'postgres' },
|
|
37
|
+
pglite: { takes: 'entities', from: 'either', addressed: true, arrives: null, migrations: true, references: true, sql: true, driver: '@electric-sql/pglite' },
|
|
38
|
+
browser: { takes: 'entities', from: 'browser', addressed: false, arrives: null, migrations: false, references: false, sql: false, driver: 'zustand' },
|
|
39
|
+
https: { takes: 'calls', from: 'either', addressed: true, arrives: null, migrations: false, references: false, sql: false, driver: null },
|
|
40
|
+
|
|
41
|
+
module: { takes: 'names', from: 'either', addressed: false, arrives: 'npm', migrations: false, references: false, sql: false, driver: null },
|
|
42
|
+
cli: { takes: 'names', from: 'server', addressed: false, arrives: 'machine', migrations: false, references: false, sql: false, driver: null },
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const SAID: Readonly<Record<Reach, string>> = {
|
|
46
|
+
postgres:
|
|
47
|
+
'A postgres this app connects to over the network. It keeps entities and holds everything a declaration can state about their shape — tables, references and invariants — so what is kept here is held to that shape by the database itself rather than by whatever remembered to check. Who may touch a row is not asked of it: that is answered by the app, against the caller who actually arrived. It is reached from a server only: a connection to it from a page is a database somebody else can open. This is where a thing goes when losing it would matter.',
|
|
48
|
+
pglite:
|
|
49
|
+
'A postgres running inside the process that opened it. It keeps entities and holds tables, references and invariants, exactly as the networked one does — what differs is who can reach it, not what it can be told to hold. What belongs here is what the caller is already entitled to see: a local copy, a working set, an offline cache.',
|
|
50
|
+
browser:
|
|
51
|
+
'A store in the browser, held in memory the viewer owns. It keeps entities and it is addressed by nothing, because it is already where the code runs. It holds no migration, because a page load builds it from nothing every time. What belongs here is what the interface needs to be responsive and can afford to lose.',
|
|
52
|
+
https:
|
|
53
|
+
'Something this app calls over https — a vendor, a service, another app. It accepts calls rather than entities: each is declared with the verb and the path it takes, so a node names the call and never writes a url. Its address and its credential are both secrets, and whether it may be reached from a browser is decided by which kind of secret it needs rather than by the reach itself.',
|
|
54
|
+
module:
|
|
55
|
+
'Something this app brings in from outside the repository and calls — an npm package, an SDK, an installer. Declaring one installs it and reads the methods it publishes, then asks which of them this app may call. What is picked is written down, and a method that is not on that list is refused before the package is imported: what can ever run is fixed when the target is declared, not by whoever calls it. A credential is named only if it needs one, and it comes from the vault at the moment of the call and is never written anywhere. A package you only import is a dependency rather than one of these, and the manifest already declares it.',
|
|
56
|
+
cli:
|
|
57
|
+
'A program this app runs as a command — git is the one that made this necessary. It is not installed, because it is not a dependency: it is either on the machine or it is not, and declaring one checks that it is there and says what to do when it is not. What it accepts is its subcommands, read off the program itself at the moment it is declared and written down: the whole surface the tool really has, because it was asked rather than typed, and a subcommand that is not on that list never runs. Declaring one is what brings `thread run <name>` into being, so the CLI grows a command by the app stating an integration rather than by anybody writing a verb — and an app that has not declared it has no such command at all.',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface Target extends Member {
|
|
61
|
+
|
|
62
|
+
readonly takes: readonly string[]
|
|
63
|
+
|
|
64
|
+
readonly requires: readonly string[]
|
|
65
|
+
|
|
66
|
+
readonly uses?: string
|
|
67
|
+
|
|
68
|
+
readonly offers?: readonly string[]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface Call extends Member {
|
|
72
|
+
|
|
73
|
+
readonly of: string
|
|
74
|
+
|
|
75
|
+
readonly method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
|
76
|
+
|
|
77
|
+
readonly path: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const CALL_PREFIX = 'cl_'
|
|
81
|
+
|
|
82
|
+
const FILLED = /\{([a-z][a-z0-9_]*)\}/g
|
|
83
|
+
|
|
84
|
+
export const fills = (one: Call): readonly string[] => [...one.path.matchAll(FILLED)].map((each) => each[1] ?? '')
|
|
85
|
+
|
|
86
|
+
export function reachOf(id: string): Reach | undefined {
|
|
87
|
+
return (Object.keys(REACH) as Reach[]).find((one) => id.startsWith(REACH[one]))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function holdsOf(id: string): Holds | undefined {
|
|
91
|
+
const what = reachOf(id)
|
|
92
|
+
return what === undefined ? undefined : HOLDS[what]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function driverOf(id: string): string | null {
|
|
96
|
+
return holdsOf(id)?.driver ?? null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function addressOf(one: Target, secrets: readonly Secret[]): Secret | undefined {
|
|
100
|
+
|
|
101
|
+
if (holdsOf(one.id)?.addressed !== true) return undefined
|
|
102
|
+
const held = new Map(secrets.map((each) => [each.id, each]))
|
|
103
|
+
return one.requires.map((each) => held.get(each)).find((each) => each !== undefined && each.sends === undefined)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function credentialsOf(one: Target, secrets: readonly Secret[]): readonly Secret[] {
|
|
107
|
+
const held = new Map(secrets.map((each) => [each.id, each]))
|
|
108
|
+
return one.requires
|
|
109
|
+
.map((each) => held.get(each))
|
|
110
|
+
.filter((each): each is Secret => each !== undefined && each.sends !== undefined)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function servesBrowser(one: Target, secrets: readonly Secret[]): true | string {
|
|
114
|
+
const holds = holdsOf(one.id)
|
|
115
|
+
if (holds === undefined) {
|
|
116
|
+
return `\`${one.id}\` wears none of the prefixes a target is reached by, so nothing can tell where it may be reached from`
|
|
117
|
+
}
|
|
118
|
+
if (holds.from === 'server') {
|
|
119
|
+
return (
|
|
120
|
+
`\`${one.id}\` is reached over ${String(reachOf(one.id))}, and that reach is server-only whatever it is ` +
|
|
121
|
+
'authenticated with — the refusal is about the reach itself and not about any credential'
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
const held = new Map(secrets.map((each) => [each.id, each]))
|
|
125
|
+
for (const named of one.requires) {
|
|
126
|
+
const secret = held.get(named)
|
|
127
|
+
if (secret === undefined) continue
|
|
128
|
+
if (secrecyOf(secret.id) === 'server') {
|
|
129
|
+
return (
|
|
130
|
+
`\`${one.id}\` requires \`${secret.id}\`, which wears \`${SECRECY.server}\` — a value only a server may hold. ` +
|
|
131
|
+
'Everything a browser bundle reaches is in the bundle, so this would ship the credential to whoever opens the page'
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return true
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const unreachable = (targets: readonly Target[]): readonly string[] =>
|
|
139
|
+
targets
|
|
140
|
+
.filter((one) => reachOf(one.id) === undefined)
|
|
141
|
+
.map(
|
|
142
|
+
(one) =>
|
|
143
|
+
`\`${one.id}\` wears none of the prefixes a target is reached by (${Object.values(REACH).join(', ')}), so ` +
|
|
144
|
+
'nothing can tell how to reach it — and how a thing is reached decides what may be declared for it',
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
const SHAPED: Readonly<Record<'module' | 'cli', RegExp>> = {
|
|
148
|
+
|
|
149
|
+
module: /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/,
|
|
150
|
+
|
|
151
|
+
cli: /^[a-z][a-z0-9-]*$/,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const EVERY_VALUE = new Set(['constructor', '__proto__', 'prototype'])
|
|
155
|
+
|
|
156
|
+
export function misnamed(target: string, name: string): string | undefined {
|
|
157
|
+
const reach = reachOf(target)
|
|
158
|
+
if (reach !== 'module' && reach !== 'cli') return undefined
|
|
159
|
+
if (EVERY_VALUE.has(name)) {
|
|
160
|
+
return (
|
|
161
|
+
`\`${name}\` is a step every JavaScript value has rather than something \`${target}\` offers, and walking to ` +
|
|
162
|
+
'it leaves what was declared'
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
if (!SHAPED[reach].test(name)) {
|
|
166
|
+
return reach === 'module'
|
|
167
|
+
? `\`${name}\` is not a method path — one name, or names joined by dots, e.g. \`customers.list\``
|
|
168
|
+
: `\`${name}\` is not a subcommand — one word as the program spells it, e.g. \`commit\` or \`rev-parse\``
|
|
169
|
+
}
|
|
170
|
+
return undefined
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function misaccepted(
|
|
174
|
+
targets: readonly Target[],
|
|
175
|
+
entities: readonly string[],
|
|
176
|
+
calls: readonly Call[],
|
|
177
|
+
): readonly string[] {
|
|
178
|
+
const isEntity = new Set(entities)
|
|
179
|
+
const isCall = new Set(calls.map((one) => one.id))
|
|
180
|
+
const found: string[] = []
|
|
181
|
+
for (const one of targets) {
|
|
182
|
+
const holds = holdsOf(one.id)
|
|
183
|
+
if (holds === undefined) continue
|
|
184
|
+
for (const named of one.takes) {
|
|
185
|
+
if (holds.takes === 'entities' && isEntity.has(named)) continue
|
|
186
|
+
if (holds.takes === 'calls' && isCall.has(named)) continue
|
|
187
|
+
|
|
188
|
+
if (holds.takes === 'names') {
|
|
189
|
+
found.push(
|
|
190
|
+
`\`${one.id}\` is reached as ${String(reachOf(one.id))} and its \`takes\` names "${named}" — what an ` +
|
|
191
|
+
'installed package or a program responds to goes in `offers`, not here. `takes` is a demand, and the ' +
|
|
192
|
+
'descent requires every word on one to be coined by a row in this tree; a method name never can be',
|
|
193
|
+
)
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
found.push(
|
|
197
|
+
`\`${one.id}\` is reached over ${String(reachOf(one.id))} and so accepts ${holds.takes}, and its \`takes\` names ` +
|
|
198
|
+
`"${named}", which is ${isEntity.has(named) ? 'an entity' : isCall.has(named) ? 'a call' : 'nothing this app declares'}`,
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return found
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function unaddressed(targets: readonly Target[], secrets: readonly Secret[]): readonly string[] {
|
|
206
|
+
const held = new Map(secrets.map((one) => [one.id, one]))
|
|
207
|
+
const found: string[] = []
|
|
208
|
+
for (const one of targets) {
|
|
209
|
+
const holds = holdsOf(one.id)
|
|
210
|
+
if (holds === undefined) continue
|
|
211
|
+
const addresses = one.requires
|
|
212
|
+
.map((each) => held.get(each))
|
|
213
|
+
.filter((each): each is Secret => each !== undefined && each.sends === undefined)
|
|
214
|
+
if (!holds.addressed) {
|
|
215
|
+
|
|
216
|
+
if (holds.arrives === null && addresses.length > 0) {
|
|
217
|
+
found.push(
|
|
218
|
+
`\`${one.id}\` is reached over ${String(reachOf(one.id))}, which is addressed by nothing and needs nothing to ` +
|
|
219
|
+
`reach it, and it requires \`${addresses[0]?.id ?? ''}\` — there is nowhere for that value to go`,
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
if (addresses.length === 0) {
|
|
225
|
+
found.push(
|
|
226
|
+
`\`${one.id}\` is reached over ${String(reachOf(one.id))} and nothing says where it is. Require one secret with ` +
|
|
227
|
+
'no `sends` — that one is its address. A credential with nowhere to be sent is a value nothing can use',
|
|
228
|
+
)
|
|
229
|
+
continue
|
|
230
|
+
}
|
|
231
|
+
if (addresses.length > 1) {
|
|
232
|
+
found.push(
|
|
233
|
+
`\`${one.id}\` requires ${addresses.map((each) => `\`${each.id}\``).join(' and ')}, and neither says where it goes, ` +
|
|
234
|
+
'so both read as its address. One place is reached at one address',
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return found
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function unheld(targets: readonly Target[], secrets: readonly Secret[]): readonly string[] {
|
|
242
|
+
const known = new Set(secrets.map((one) => one.id))
|
|
243
|
+
const found: string[] = []
|
|
244
|
+
for (const one of targets) {
|
|
245
|
+
for (const named of one.requires) {
|
|
246
|
+
if (known.has(named)) continue
|
|
247
|
+
found.push(
|
|
248
|
+
`\`${one.id}\` requires "${named}", and this app declares no secret by that name. A secret is declared before it ` +
|
|
249
|
+
'is required, so the one place saying who may hold a value is never whichever place happened to need it',
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return found
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function unanswered(calls: readonly Call[], targets: readonly Target[]): readonly string[] {
|
|
257
|
+
const held = new Map(targets.map((one) => [one.id, one]))
|
|
258
|
+
const found: string[] = []
|
|
259
|
+
for (const one of calls) {
|
|
260
|
+
const of = held.get(one.of)
|
|
261
|
+
if (of === undefined) {
|
|
262
|
+
found.push(`\`${one.id}\` is a call on "${one.of}", and this app declares no target by that name`)
|
|
263
|
+
continue
|
|
264
|
+
}
|
|
265
|
+
const holds = holdsOf(of.id)
|
|
266
|
+
if (holds !== undefined && holds.takes !== 'calls') {
|
|
267
|
+
found.push(
|
|
268
|
+
`\`${one.id}\` is a call on \`${of.id}\`, which is reached over ${String(reachOf(of.id))} and keeps entities. ` +
|
|
269
|
+
'A place you keep rows in is not a place you issue verbs at',
|
|
270
|
+
)
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
if (!of.takes.includes(one.id)) {
|
|
274
|
+
found.push(
|
|
275
|
+
`\`${one.id}\` is a call on \`${of.id}\` and \`${of.id}\` does not accept it. Add it to that target's \`takes\` — ` +
|
|
276
|
+
'what a target accepts is read off the target, not gathered from whatever pointed at it',
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return found
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function misused(targets: readonly Target[]): readonly string[] {
|
|
284
|
+
const found: string[] = []
|
|
285
|
+
for (const one of targets) {
|
|
286
|
+
const holds = holdsOf(one.id)
|
|
287
|
+
if (holds === undefined) continue
|
|
288
|
+
if (holds.arrives !== null && (one.uses ?? '') === '') {
|
|
289
|
+
found.push(
|
|
290
|
+
`\`${one.id}\` is reached as ${String(reachOf(one.id))} and does not say what it uses — ` +
|
|
291
|
+
`${holds.arrives === 'npm' ? 'the npm package to install' : 'the program to run'} goes in \`uses\`, because the ` +
|
|
292
|
+
'id is what this app calls it and not what it is',
|
|
293
|
+
)
|
|
294
|
+
continue
|
|
295
|
+
}
|
|
296
|
+
if (holds.arrives === null && one.uses !== undefined) {
|
|
297
|
+
found.push(
|
|
298
|
+
`\`${one.id}\` is reached over ${String(reachOf(one.id))}, where nothing is installed and nothing is spawned, ` +
|
|
299
|
+
`and it states \`uses: '${one.uses}'\` — there is nothing for that name to be`,
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return found
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function refusals(
|
|
307
|
+
targets: readonly Target[],
|
|
308
|
+
secrets: readonly Secret[],
|
|
309
|
+
calls: readonly Call[],
|
|
310
|
+
entities: readonly string[],
|
|
311
|
+
): readonly string[] {
|
|
312
|
+
return [
|
|
313
|
+
...unreachable(targets),
|
|
314
|
+
...unheld(targets, secrets),
|
|
315
|
+
...unaddressed(targets, secrets),
|
|
316
|
+
...unanswered(calls, targets),
|
|
317
|
+
...misaccepted(targets, entities, calls),
|
|
318
|
+
...misused(targets),
|
|
319
|
+
]
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function targetOf(entity: string, targets: readonly Target[]): Target | string {
|
|
323
|
+
const keeping = targets.filter((one) => holdsOf(one.id)?.takes === 'entities')
|
|
324
|
+
const found = keeping.filter((one) => one.takes.includes(entity))
|
|
325
|
+
const only = found[0]
|
|
326
|
+
if (only === undefined) {
|
|
327
|
+
return (
|
|
328
|
+
`"${entity}" is kept by no target. Add its id to the \`takes\` of one of ` +
|
|
329
|
+
`${keeping.length === 0 ? '(this app declares none that keep things)' : keeping.map((one) => `\`${one.id}\``).join(', ')} — ` +
|
|
330
|
+
'an entity nothing keeps is a table no database builds, and nothing else here would ever say so'
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
if (found.length > 1) {
|
|
334
|
+
return (
|
|
335
|
+
`"${entity}" is kept by ${found.map((one) => `\`${one.id}\``).join(' and ')}. One thing kept in two places is two ` +
|
|
336
|
+
'things that will disagree, and neither declaration says which one is right'
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
return only
|
|
340
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Env } from '../core/actor.ts';
|
|
2
2
|
export declare const VAULT_DIR = ".thread";
|
|
3
|
+
export declare const SEALED_NAME = "vault.sealed.json";
|
|
4
|
+
export declare const KEY_NAME = "vault.key";
|
|
3
5
|
export declare const SEALED_AT = ".thread/vault.sealed.json";
|
|
4
6
|
export declare const KEY_AT = ".thread/vault.key";
|
|
5
7
|
export declare const ALGORITHM = "aes-256-gcm";
|
|
@@ -2,8 +2,10 @@ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
export const VAULT_DIR = '.thread';
|
|
5
|
-
export const
|
|
6
|
-
export const
|
|
5
|
+
export const SEALED_NAME = 'vault.sealed.json';
|
|
6
|
+
export const KEY_NAME = 'vault.key';
|
|
7
|
+
export const SEALED_AT = `${VAULT_DIR}/${SEALED_NAME}`;
|
|
8
|
+
export const KEY_AT = `${VAULT_DIR}/${KEY_NAME}`;
|
|
7
9
|
export const ALGORITHM = 'aes-256-gcm';
|
|
8
10
|
export const KEY_BYTES = 32;
|
|
9
11
|
const IV_BYTES = 12;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import type { Env } from '../core/actor.ts'
|
|
6
|
+
|
|
7
|
+
export const VAULT_DIR = '.thread'
|
|
8
|
+
|
|
9
|
+
export const SEALED_NAME = 'vault.sealed.json'
|
|
10
|
+
|
|
11
|
+
export const KEY_NAME = 'vault.key'
|
|
12
|
+
|
|
13
|
+
export const SEALED_AT = `${VAULT_DIR}/${SEALED_NAME}`
|
|
14
|
+
|
|
15
|
+
export const KEY_AT = `${VAULT_DIR}/${KEY_NAME}`
|
|
16
|
+
|
|
17
|
+
export const ALGORITHM = 'aes-256-gcm'
|
|
18
|
+
|
|
19
|
+
export const KEY_BYTES = 32
|
|
20
|
+
|
|
21
|
+
const IV_BYTES = 12
|
|
22
|
+
|
|
23
|
+
const noKey = (root: string): Error =>
|
|
24
|
+
new Error(
|
|
25
|
+
`there is no vault key at ${join(root, KEY_AT)}, so nothing here can open a sealed value. It is deliberately not ` +
|
|
26
|
+
'in version control — whoever set this app up has it. Ask them for it, or run `thread create secret`, which ' +
|
|
27
|
+
'generates one when there is none.',
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
const unsealable = (named: string, why: string): Error =>
|
|
31
|
+
new Error(
|
|
32
|
+
`"${named}" is in the sealed store and would not open: ${why}. Either the key is not the one it was sealed under, ` +
|
|
33
|
+
'or the store has been edited by hand — it is ciphertext, and there is nothing in it to edit.',
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const undeclared = (named: string, known: readonly string[]): Error =>
|
|
37
|
+
new Error(
|
|
38
|
+
`nothing may ask for a secret this app does not declare, and "${named}" is not one of ` +
|
|
39
|
+
`${known.length === 0 ? '(this app declares none)' : known.join(', ')}. A value is asked for by something that ` +
|
|
40
|
+
'declared it needed it, or not at all.',
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
const unset = (named: string, env: Env): Error =>
|
|
44
|
+
new Error(
|
|
45
|
+
`"${named}" is declared and has no value sealed for \`${env}\`. Run \`thread create secret\` and give it one — a ` +
|
|
46
|
+
'declared secret nothing was ever given is refused here rather than resolved to an empty string.',
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
type Store = Record<string, Partial<Record<Env, string>>>
|
|
50
|
+
|
|
51
|
+
const store = (root: string): Store => {
|
|
52
|
+
const at = join(root, SEALED_AT)
|
|
53
|
+
if (!existsSync(at)) return {}
|
|
54
|
+
try {
|
|
55
|
+
const held: unknown = JSON.parse(readFileSync(at, 'utf8'))
|
|
56
|
+
return typeof held === 'object' && held !== null ? (held as Store) : {}
|
|
57
|
+
} catch {
|
|
58
|
+
return {}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const keyOf = (root: string): Buffer | undefined => {
|
|
63
|
+
const at = join(root, KEY_AT)
|
|
64
|
+
if (!existsSync(at)) return undefined
|
|
65
|
+
try {
|
|
66
|
+
const held = Buffer.from(readFileSync(at, 'utf8').trim(), 'base64')
|
|
67
|
+
return held.length === KEY_BYTES ? held : undefined
|
|
68
|
+
} catch {
|
|
69
|
+
return undefined
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function keyed(root: string): Buffer {
|
|
74
|
+
const held = keyOf(root)
|
|
75
|
+
if (held !== undefined) return held
|
|
76
|
+
const made = randomBytes(KEY_BYTES)
|
|
77
|
+
mkdirSync(join(root, VAULT_DIR), { recursive: true })
|
|
78
|
+
writeFileSync(join(root, KEY_AT), `${made.toString('base64')}\n`, { mode: 0o600 })
|
|
79
|
+
return made
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function seal(root: string, named: string, env: Env, value: string): void {
|
|
83
|
+
const key = keyed(root)
|
|
84
|
+
const iv = randomBytes(IV_BYTES)
|
|
85
|
+
const cipher = createCipheriv(ALGORITHM, key, iv)
|
|
86
|
+
const body = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])
|
|
87
|
+
const held = store(root)
|
|
88
|
+
held[named] = { ...held[named], [env]: [iv, cipher.getAuthTag(), body].map((one) => one.toString('base64')).join('.') }
|
|
89
|
+
const sorted = Object.fromEntries(Object.keys(held).sort().map((one) => [one, held[one]]))
|
|
90
|
+
const at = join(root, SEALED_AT)
|
|
91
|
+
mkdirSync(dirname(at), { recursive: true })
|
|
92
|
+
writeFileSync(at, `${JSON.stringify(sorted, null, 2)}\n`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const sealedIn = (root: string, env: Env): readonly string[] =>
|
|
96
|
+
Object.entries(store(root))
|
|
97
|
+
.filter(([, held]) => held[env] !== undefined)
|
|
98
|
+
.map(([named]) => named)
|
|
99
|
+
.sort()
|
|
100
|
+
|
|
101
|
+
function unsealed(key: Buffer, named: string, held: string): string {
|
|
102
|
+
const parts = held.split('.')
|
|
103
|
+
if (parts.length !== 3) throw unsealable(named, 'it is not three base64 parts separated by dots')
|
|
104
|
+
const [iv, tag, body] = parts.map((one) => Buffer.from(one, 'base64')) as [Buffer, Buffer, Buffer]
|
|
105
|
+
try {
|
|
106
|
+
const decipher = createDecipheriv(ALGORITHM, key, iv)
|
|
107
|
+
decipher.setAuthTag(tag)
|
|
108
|
+
return `${decipher.update(body).toString('utf8')}${decipher.final('utf8')}`
|
|
109
|
+
} catch (thrown) {
|
|
110
|
+
throw unsealable(named, thrown instanceof Error ? thrown.message : String(thrown))
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function opened(
|
|
115
|
+
root: string,
|
|
116
|
+
declared: readonly string[],
|
|
117
|
+
env: Env,
|
|
118
|
+
): (refs: readonly string[]) => Promise<Readonly<Record<string, string>>> {
|
|
119
|
+
const known = new Set(declared)
|
|
120
|
+
return async (refs) => {
|
|
121
|
+
if (refs.length === 0) return {}
|
|
122
|
+
const key = keyOf(root)
|
|
123
|
+
if (key === undefined) throw noKey(root)
|
|
124
|
+
const held = store(root)
|
|
125
|
+
const out: Record<string, string> = {}
|
|
126
|
+
for (const named of refs) {
|
|
127
|
+
if (!known.has(named)) throw undeclared(named, [...known].sort())
|
|
128
|
+
const sealed = held[named]?.[env]
|
|
129
|
+
if (sealed === undefined) throw unset(named, env)
|
|
130
|
+
out[named] = unsealed(key, named, sealed)
|
|
131
|
+
}
|
|
132
|
+
return out
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const IGNORES: readonly string[] = [KEY_AT]
|