@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.
Files changed (150) hide show
  1. package/dist/core/actor.ts +20 -0
  2. package/dist/core/asking.d.ts +0 -1
  3. package/dist/core/asking.js +0 -1
  4. package/dist/core/asking.ts +1342 -0
  5. package/dist/core/attach.js +2 -2
  6. package/dist/core/attach.ts +585 -0
  7. package/dist/core/chain.js +8 -1
  8. package/dist/core/chain.ts +148 -0
  9. package/dist/core/cli.js +97 -18
  10. package/dist/core/cli.ts +2004 -0
  11. package/dist/core/condition.ts +227 -0
  12. package/dist/core/descend.d.ts +1 -0
  13. package/dist/core/descend.js +36 -0
  14. package/dist/core/descend.ts +1001 -0
  15. package/dist/core/finding.ts +38 -0
  16. package/dist/core/generators/meta.js +6 -0
  17. package/dist/core/generators/meta.ts +1006 -0
  18. package/dist/core/init.ts +170 -0
  19. package/dist/core/invoked.ts +47 -0
  20. package/dist/core/round.ts +57 -0
  21. package/dist/core/rows.ts +369 -0
  22. package/dist/core/scope.ts +18 -0
  23. package/dist/core/thread.ts +29 -0
  24. package/dist/core/tree.ts +67 -0
  25. package/dist/core/types.ts +350 -0
  26. package/dist/derivation/attach.js +1 -1
  27. package/dist/derivation/attach.ts +68 -0
  28. package/dist/derivation/generators/build.ts +232 -0
  29. package/dist/derivation/generators/check.ts +118 -0
  30. package/dist/derivation/generators/derivation.ts +347 -0
  31. package/dist/derivation/generators/determinism.ts +145 -0
  32. package/dist/derivation/generators/release.js +19 -9
  33. package/dist/derivation/generators/release.ts +213 -0
  34. package/dist/derivation/makers/release.ts +135 -0
  35. package/dist/derivation/release.ts +23 -0
  36. package/dist/integrations/attach.js +2 -2
  37. package/dist/integrations/attach.ts +207 -0
  38. package/dist/integrations/generators/read.ts +127 -0
  39. package/dist/integrations/generators/secret.ts +289 -0
  40. package/dist/integrations/generators/target.js +16 -6
  41. package/dist/integrations/generators/target.ts +944 -0
  42. package/dist/integrations/generators/vault.js +46 -14
  43. package/dist/integrations/generators/vault.ts +212 -0
  44. package/dist/integrations/{sdk.js → module.js} +4 -4
  45. package/dist/integrations/module.ts +189 -0
  46. package/dist/integrations/secret.ts +50 -0
  47. package/dist/integrations/target.d.ts +3 -1
  48. package/dist/integrations/target.js +14 -11
  49. package/dist/integrations/target.ts +340 -0
  50. package/dist/integrations/vault.d.ts +2 -0
  51. package/dist/integrations/vault.js +4 -2
  52. package/dist/integrations/vault.ts +136 -0
  53. package/dist/integrity/attach.ts +234 -0
  54. package/dist/integrity/check-threads.js +2 -26
  55. package/dist/integrity/check-threads.ts +166 -0
  56. package/dist/integrity/constraint.ts +1202 -0
  57. package/dist/integrity/generators/compiler.ts +161 -0
  58. package/dist/integrity/generators/gate.ts +201 -0
  59. package/dist/integrity/generators/issues.ts +160 -0
  60. package/dist/integrity/generators/publish.js +14 -5
  61. package/dist/integrity/generators/publish.ts +247 -0
  62. package/dist/integrity/generators/rule.ts +271 -0
  63. package/dist/integrity/generators/seal.ts +117 -0
  64. package/dist/integrity/generators/trial.ts +320 -0
  65. package/dist/integrity/lint.ts +227 -0
  66. package/dist/integrity/lsp/bin.ts +42 -0
  67. package/dist/integrity/lsp/diagnostics.ts +92 -0
  68. package/dist/integrity/lsp/findings.ts +63 -0
  69. package/dist/integrity/lsp/lint.ts +140 -0
  70. package/dist/integrity/lsp/protocol.ts +78 -0
  71. package/dist/integrity/lsp/server.ts +224 -0
  72. package/dist/integrity/sealed.js +2 -2
  73. package/dist/integrity/sealed.ts +56 -0
  74. package/dist/integrity/slopnator.ts +350 -0
  75. package/dist/integrity/slopnators/sl_broken_lineage.ts +36 -0
  76. package/dist/integrity/slopnators/sl_declared_elsewhere.ts +39 -0
  77. package/dist/integrity/slopnators/sl_derived_drift.ts +98 -0
  78. package/dist/integrity/slopnators/sl_hardcoded_style.ts +55 -0
  79. package/dist/integrity/slopnators/sl_inert_island.ts +91 -0
  80. package/dist/integrity/slopnators/sl_inert_slopnator.ts +93 -0
  81. package/dist/integrity/slopnators/sl_missing_row.ts +182 -0
  82. package/dist/integrity/slopnators/sl_no_barrel.ts +48 -0
  83. package/dist/integrity/slopnators/sl_no_child_process.ts +74 -0
  84. package/dist/integrity/slopnators/sl_no_comment.ts +43 -0
  85. package/dist/integrity/slopnators/sl_no_dispatch_bypass.ts +328 -0
  86. package/dist/integrity/slopnators/sl_no_import_cycle.ts +50 -0
  87. package/dist/integrity/slopnators/sl_no_such_command.ts +64 -0
  88. package/dist/integrity/slopnators/sl_only_takes_a_literal.ts +63 -0
  89. package/dist/integrity/slopnators/sl_secret_literal.ts +100 -0
  90. package/dist/integrity/slopnators/sl_stale_path.ts +94 -0
  91. package/dist/integrity/slopnators/sl_twice_meant.ts +31 -0
  92. package/dist/integrity/slopnators/sl_twice_parented.ts +37 -0
  93. package/dist/integrity/slopnators/sl_undeclared_crossing.ts +87 -0
  94. package/dist/integrity/slopnators/sl_undeclared_destination.ts +65 -0
  95. package/dist/integrity/slopnators/sl_undeclared_element.ts +44 -0
  96. package/dist/integrity/slopnators/sl_undeclared_node.ts +69 -0
  97. package/dist/integrity/slopnators/sl_undeclared_operation.ts +147 -0
  98. package/dist/integrity/slopnators/sl_undeclared_variant.ts +148 -0
  99. package/dist/integrity/slopnators/sl_undescribed.ts +65 -0
  100. package/dist/integrity/slopnators/sl_ungoverned_island.ts +61 -0
  101. package/dist/integrity/slopnators/sl_unkept_rows.ts +111 -0
  102. package/dist/integrity/slopnators/sl_unnamed_island.ts +41 -0
  103. package/dist/integrity/slopnators/sl_unpaired_member.ts +131 -0
  104. package/dist/integrity/slopnators/sl_unpaired_slopnator.ts +58 -0
  105. package/dist/integrity/slopnators/sl_unprefixed_member.ts +136 -0
  106. package/dist/integrity/slopnators/sl_unseamed_reach.ts +79 -0
  107. package/dist/integrity/slopnators/sl_unthreaded.ts +419 -0
  108. package/dist/integrity/slopnators/sl_untranslated_key.ts +164 -0
  109. package/dist/integrity/slopnators/sl_unversioned.ts +36 -0
  110. package/dist/integrity/sweep.ts +307 -0
  111. package/dist/knowledge/attach.ts +350 -0
  112. package/dist/knowledge/generators/docsgen.ts +174 -0
  113. package/dist/knowledge/generators/glossary.ts +73 -0
  114. package/dist/knowledge/generators/skills.ts +263 -0
  115. package/dist/knowledge/glossary.ts +6 -0
  116. package/dist/operations/access.ts +88 -0
  117. package/dist/operations/attach.ts +475 -0
  118. package/dist/operations/capabilities.ts +65 -0
  119. package/dist/operations/cost.ts +58 -0
  120. package/dist/operations/dispatch.ts +348 -0
  121. package/dist/operations/error-results.ts +31 -0
  122. package/dist/operations/errors/catalog.ts +168 -0
  123. package/dist/operations/errors/classes.ts +51 -0
  124. package/dist/operations/errors/errors.ts +314 -0
  125. package/dist/operations/generators/capability.js +29 -19
  126. package/dist/operations/generators/capability.ts +704 -0
  127. package/dist/operations/generators/compile.js +20 -1
  128. package/dist/operations/generators/compile.ts +643 -0
  129. package/dist/operations/generators/grant.ts +336 -0
  130. package/dist/operations/generators/operation.ts +972 -0
  131. package/dist/operations/generators/permits.ts +223 -0
  132. package/dist/operations/generators/seed.ts +347 -0
  133. package/dist/operations/generators/trial.js +4 -1
  134. package/dist/operations/generators/trial.ts +529 -0
  135. package/dist/operations/http.ts +164 -0
  136. package/dist/operations/makers/capability.ts +166 -0
  137. package/dist/operations/makers/trial.ts +520 -0
  138. package/dist/operations/memory.ts +470 -0
  139. package/dist/operations/metering.ts +328 -0
  140. package/dist/operations/nodes.ts +54 -0
  141. package/dist/operations/operations.ts +32 -0
  142. package/dist/operations/queue-policy.ts +10 -0
  143. package/dist/operations/result.ts +28 -0
  144. package/dist/operations/seams.d.ts +4 -4
  145. package/dist/operations/seams.ts +364 -0
  146. package/dist/operations/store.ts +29 -0
  147. package/package.json +8 -1
  148. package/dist/integrations/generators/sdk.d.ts +0 -1
  149. package/dist/integrations/generators/sdk.js +0 -148
  150. /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
- sdk: 'sdk_',
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
- sdk: { takes: 'names', from: 'either', addressed: false, arrives: 'npm', migrations: false, references: false, sql: false },
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
- sdk: 'An npm package this app calls directly instead of over the wire — stripe, supabase, neon. 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. Credentials come from the vault at the moment of the call and are never written anywhere.',
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
- sdk: /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/,
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 !== 'sdk' && reach !== 'cli')
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 === 'sdk'
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 SEALED_AT = `${VAULT_DIR}/vault.sealed.json`;
6
- export const KEY_AT = `${VAULT_DIR}/vault.key`;
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]