@aventara/access 0.0.0-stage → 0.1.0-rc.0

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 (240) hide show
  1. package/CHANGELOG.md +831 -0
  2. package/LICENSE +91 -0
  3. package/LICENSE-ADDITIONAL-PERMISSION.md +9 -0
  4. package/README.md +2439 -2
  5. package/dist/access/access-options.d.ts +66 -0
  6. package/dist/access/access-options.js +5 -0
  7. package/dist/access/access.error.d.ts +35 -0
  8. package/dist/access/access.error.js +11 -0
  9. package/dist/access/access.factory.d.ts +273 -0
  10. package/dist/access/access.factory.js +440 -0
  11. package/dist/access/better-auth.hardening.d.ts +120 -0
  12. package/dist/access/better-auth.hardening.js +420 -0
  13. package/dist/access/better-auth.loader.d.ts +67 -0
  14. package/dist/access/better-auth.loader.js +65 -0
  15. package/dist/access/better-auth.range.d.ts +9 -0
  16. package/dist/access/better-auth.range.js +28 -0
  17. package/dist/access/better-auth.require.d.ts +90 -0
  18. package/dist/access/better-auth.require.js +6 -0
  19. package/dist/access/better-auth.require.node.d.ts +7 -0
  20. package/dist/access/better-auth.require.node.js +52 -0
  21. package/dist/access/claims.mapper.d.ts +79 -0
  22. package/dist/access/claims.mapper.js +152 -0
  23. package/dist/access/key-set.cache.d.ts +10 -0
  24. package/dist/access/key-set.cache.js +63 -0
  25. package/dist/access/policies-block.checker.d.ts +64 -0
  26. package/dist/access/policies-block.checker.js +138 -0
  27. package/dist/access/principal.subject.d.ts +12 -0
  28. package/dist/access/principal.subject.js +60 -0
  29. package/dist/access/profile.policy.d.ts +74 -0
  30. package/dist/access/profile.policy.js +110 -0
  31. package/dist/access/request.state.d.ts +89 -0
  32. package/dist/access/request.state.js +196 -0
  33. package/dist/access/session.hooks.d.ts +114 -0
  34. package/dist/access/session.hooks.js +258 -0
  35. package/dist/access/token.verifier.d.ts +35 -0
  36. package/dist/access/token.verifier.js +69 -0
  37. package/dist/access-schema.d.ts +48 -0
  38. package/dist/access-schema.js +5 -0
  39. package/dist/access-warning.emitter.d.ts +2 -0
  40. package/dist/access-warning.emitter.js +20 -0
  41. package/dist/access.builder.d.ts +299 -0
  42. package/dist/access.builder.js +66 -0
  43. package/dist/access.engine.d.ts +142 -0
  44. package/dist/access.engine.js +470 -0
  45. package/dist/audit/audit-event.d.ts +135 -0
  46. package/dist/audit/audit-event.js +8 -0
  47. package/dist/audit/audit.log.d.ts +122 -0
  48. package/dist/audit/audit.log.js +345 -0
  49. package/dist/audit/audit.schema.d.ts +143 -0
  50. package/dist/audit/audit.schema.js +50 -0
  51. package/dist/auth/index.d.ts +12 -0
  52. package/dist/auth/index.js +2 -0
  53. package/dist/aventara-where.translator.d.ts +19 -0
  54. package/dist/aventara-where.translator.js +87 -0
  55. package/dist/beacon/beacon.check.d.ts +59 -0
  56. package/dist/beacon/beacon.check.js +128 -0
  57. package/dist/beacon/beacon.schema.d.ts +41 -0
  58. package/dist/beacon/beacon.schema.js +48 -0
  59. package/dist/beacon/beacon.store.d.ts +29 -0
  60. package/dist/beacon/beacon.store.js +78 -0
  61. package/dist/better-auth/access.client-plugin.d.ts +75 -0
  62. package/dist/better-auth/access.client-plugin.js +133 -0
  63. package/dist/better-auth/access.plugin.d.ts +164 -0
  64. package/dist/better-auth/access.plugin.js +348 -0
  65. package/dist/better-auth/access.provider.d.ts +78 -0
  66. package/dist/better-auth/access.provider.js +100 -0
  67. package/dist/better-auth/index.d.ts +7 -0
  68. package/dist/better-auth/index.js +4 -0
  69. package/dist/better-auth/organization.bridge.d.ts +66 -0
  70. package/dist/better-auth/organization.bridge.js +144 -0
  71. package/dist/canonical-json.writer.d.ts +12 -0
  72. package/dist/canonical-json.writer.js +30 -0
  73. package/dist/cli/avaccess.bin.d.ts +2 -0
  74. package/dist/cli/avaccess.bin.js +26 -0
  75. package/dist/cli/avaccess.program.d.ts +15 -0
  76. package/dist/cli/avaccess.program.js +166 -0
  77. package/dist/cli/beacon-pins.file.d.ts +14 -0
  78. package/dist/cli/beacon-pins.file.js +62 -0
  79. package/dist/cli/beacon.loader.d.ts +20 -0
  80. package/dist/cli/beacon.loader.js +71 -0
  81. package/dist/cli/cli.context.d.ts +49 -0
  82. package/dist/cli/cli.context.js +66 -0
  83. package/dist/cli/diff.command.d.ts +2 -0
  84. package/dist/cli/diff.command.js +66 -0
  85. package/dist/cli/generate.command.d.ts +2 -0
  86. package/dist/cli/generate.command.js +82 -0
  87. package/dist/cli/keys.command.d.ts +2 -0
  88. package/dist/cli/keys.command.js +96 -0
  89. package/dist/cli/lock.file.d.ts +31 -0
  90. package/dist/cli/lock.file.js +181 -0
  91. package/dist/cli/module.loader.d.ts +13 -0
  92. package/dist/cli/module.loader.js +63 -0
  93. package/dist/cli/policies.diff.d.ts +16 -0
  94. package/dist/cli/policies.diff.js +83 -0
  95. package/dist/cli/pull.command.d.ts +2 -0
  96. package/dist/cli/pull.command.js +234 -0
  97. package/dist/cli/push.command.d.ts +2 -0
  98. package/dist/cli/push.command.js +59 -0
  99. package/dist/cli/registry.client.d.ts +63 -0
  100. package/dist/cli/registry.client.js +287 -0
  101. package/dist/cli/schema.command.d.ts +2 -0
  102. package/dist/cli/schema.command.js +60 -0
  103. package/dist/cli/serve.command.d.ts +6 -0
  104. package/dist/cli/serve.command.js +200 -0
  105. package/dist/cli/status.command.d.ts +2 -0
  106. package/dist/cli/status.command.js +31 -0
  107. package/dist/cli/translator.flag.d.ts +6 -0
  108. package/dist/cli/translator.flag.js +24 -0
  109. package/dist/cli/verify.command.d.ts +2 -0
  110. package/dist/cli/verify.command.js +40 -0
  111. package/dist/client/access-client.factory.d.ts +92 -0
  112. package/dist/client/access-client.factory.js +269 -0
  113. package/dist/client/access-verbs.schema.d.ts +66 -0
  114. package/dist/client/access-verbs.schema.js +159 -0
  115. package/dist/client/verb-rate.limiter.d.ts +116 -0
  116. package/dist/client/verb-rate.limiter.js +358 -0
  117. package/dist/foreign/foreign-facts.d.ts +30 -0
  118. package/dist/foreign/foreign-facts.js +75 -0
  119. package/dist/foreign/foreign.merger.d.ts +10 -0
  120. package/dist/foreign/foreign.merger.js +52 -0
  121. package/dist/foreign-references.check.d.ts +12 -0
  122. package/dist/foreign-references.check.js +134 -0
  123. package/dist/generator/client.emitter.d.ts +28 -0
  124. package/dist/generator/client.emitter.js +411 -0
  125. package/dist/generator/copied-modules.reader.d.ts +14 -0
  126. package/dist/generator/copied-modules.reader.js +80 -0
  127. package/dist/generator/namespace.facts.d.ts +28 -0
  128. package/dist/generator/namespace.facts.js +32 -0
  129. package/dist/generator/orm.translator.d.ts +48 -0
  130. package/dist/generator/orm.translator.js +29 -0
  131. package/dist/generator/prisma7/prisma-schema.reader.d.ts +9 -0
  132. package/dist/generator/prisma7/prisma-schema.reader.js +394 -0
  133. package/dist/generator/prisma7/prisma-where.translator.d.ts +72 -0
  134. package/dist/generator/prisma7/prisma-where.translator.js +136 -0
  135. package/dist/generator/prisma7/prisma7.translator.d.ts +2 -0
  136. package/dist/generator/prisma7/prisma7.translator.js +87 -0
  137. package/dist/generator/schema.emitter.d.ts +14 -0
  138. package/dist/generator/schema.emitter.js +23 -0
  139. package/dist/generator/tsconfig.reader.d.ts +44 -0
  140. package/dist/generator/tsconfig.reader.js +110 -0
  141. package/dist/generator/typescript-literal.writer.d.ts +5 -0
  142. package/dist/generator/typescript-literal.writer.js +42 -0
  143. package/dist/guardrails/guardrails-contract.d.ts +45 -0
  144. package/dist/guardrails/guardrails-contract.js +12 -0
  145. package/dist/guardrails/guardrails.builder.d.ts +48 -0
  146. package/dist/guardrails/guardrails.builder.js +32 -0
  147. package/dist/guardrails/guardrails.compiler.d.ts +10 -0
  148. package/dist/guardrails/guardrails.compiler.js +254 -0
  149. package/dist/guardrails/guardrails.composer.d.ts +7 -0
  150. package/dist/guardrails/guardrails.composer.js +95 -0
  151. package/dist/guardrails/guardrails.hash.d.ts +10 -0
  152. package/dist/guardrails/guardrails.hash.js +38 -0
  153. package/dist/index.d.ts +16 -0
  154. package/dist/index.js +6 -0
  155. package/dist/kiosk/kiosk-verbs.schema.d.ts +19 -0
  156. package/dist/kiosk/kiosk-verbs.schema.js +61 -0
  157. package/dist/kiosk/kiosk.plugin.d.ts +217 -0
  158. package/dist/kiosk/kiosk.plugin.js +877 -0
  159. package/dist/kiosk/kiosk.schema.d.ts +176 -0
  160. package/dist/kiosk/kiosk.schema.js +101 -0
  161. package/dist/kiosk/kiosk.store.d.ts +171 -0
  162. package/dist/kiosk/kiosk.store.js +478 -0
  163. package/dist/kiosk/lan-range.policy.d.ts +31 -0
  164. package/dist/kiosk/lan-range.policy.js +113 -0
  165. package/dist/kiosk/pin.policy.d.ts +9 -0
  166. package/dist/kiosk/pin.policy.js +143 -0
  167. package/dist/kiosk/session-use.marker.d.ts +4 -0
  168. package/dist/kiosk/session-use.marker.js +16 -0
  169. package/dist/lock/lock-contract.d.ts +38 -0
  170. package/dist/lock/lock-contract.js +67 -0
  171. package/dist/lock/lock.verifier.d.ts +27 -0
  172. package/dist/lock/lock.verifier.js +62 -0
  173. package/dist/mcp/agent-scope.gate.d.ts +11 -0
  174. package/dist/mcp/agent-scope.gate.js +75 -0
  175. package/dist/mcp/agent.grant.d.ts +36 -0
  176. package/dist/mcp/agent.grant.js +92 -0
  177. package/dist/mcp/mcp-options.d.ts +60 -0
  178. package/dist/mcp/mcp-options.js +72 -0
  179. package/dist/oauth/access-token.verifier.d.ts +66 -0
  180. package/dist/oauth/access-token.verifier.js +201 -0
  181. package/dist/oauth/oauth-provider.hardening.d.ts +62 -0
  182. package/dist/oauth/oauth-provider.hardening.js +261 -0
  183. package/dist/passwords/argon2id.hasher.d.ts +97 -0
  184. package/dist/passwords/argon2id.hasher.js +256 -0
  185. package/dist/passwords/node-rs-argon2.adapter.d.ts +25 -0
  186. package/dist/passwords/node-rs-argon2.adapter.js +42 -0
  187. package/dist/passwords/passwords.options.d.ts +79 -0
  188. package/dist/passwords/passwords.options.js +212 -0
  189. package/dist/policies-contract.d.ts +192 -0
  190. package/dist/policies-contract.js +8 -0
  191. package/dist/policies.compiler.d.ts +36 -0
  192. package/dist/policies.compiler.js +1358 -0
  193. package/dist/policies.decider.d.ts +103 -0
  194. package/dist/policies.decider.js +382 -0
  195. package/dist/policies.hash.d.ts +10 -0
  196. package/dist/policies.hash.js +53 -0
  197. package/dist/predicate.evaluator.d.ts +62 -0
  198. package/dist/predicate.evaluator.js +378 -0
  199. package/dist/random-secret.generator.d.ts +2 -0
  200. package/dist/random-secret.generator.js +15 -0
  201. package/dist/reference-reads.check.d.ts +12 -0
  202. package/dist/reference-reads.check.js +148 -0
  203. package/dist/registry/access.registry.d.ts +173 -0
  204. package/dist/registry/access.registry.js +865 -0
  205. package/dist/registry/beacon.ledger.d.ts +30 -0
  206. package/dist/registry/beacon.ledger.js +93 -0
  207. package/dist/registry/ownership.check.d.ts +54 -0
  208. package/dist/registry/ownership.check.js +215 -0
  209. package/dist/registry/registry.endpoints.d.ts +50 -0
  210. package/dist/registry/registry.endpoints.js +193 -0
  211. package/dist/registry/registry.key.d.ts +7 -0
  212. package/dist/registry/registry.key.js +13 -0
  213. package/dist/registry/registry.options.d.ts +13 -0
  214. package/dist/registry/registry.options.js +28 -0
  215. package/dist/registry/registry.refusal.d.ts +6 -0
  216. package/dist/registry/registry.refusal.js +11 -0
  217. package/dist/registry/registry.schema.d.ts +238 -0
  218. package/dist/registry/registry.schema.js +127 -0
  219. package/dist/resources/auth-tables.catalog.d.ts +91 -0
  220. package/dist/resources/auth-tables.catalog.js +102 -0
  221. package/dist/resources/auth-tables.guard.d.ts +95 -0
  222. package/dist/resources/auth-tables.guard.js +405 -0
  223. package/dist/scope.resolver.d.ts +138 -0
  224. package/dist/scope.resolver.js +512 -0
  225. package/dist/sha256.digest.d.ts +2 -0
  226. package/dist/sha256.digest.js +81 -0
  227. package/dist/standalone/access.server.d.ts +41 -0
  228. package/dist/standalone/access.server.js +301 -0
  229. package/dist/standalone/index.d.ts +1 -0
  230. package/dist/standalone/index.js +1 -0
  231. package/package.json +101 -3
  232. package/src/aventara-where.translator.ts +116 -0
  233. package/src/beacon/beacon.check.ts +204 -0
  234. package/src/canonical-json.writer.ts +51 -0
  235. package/src/generator/prisma7/prisma-where.translator.ts +241 -0
  236. package/src/policies-contract.ts +184 -0
  237. package/src/policies.decider.ts +650 -0
  238. package/src/predicate.evaluator.ts +574 -0
  239. package/src/scope.resolver.ts +791 -0
  240. package/src/sha256.digest.ts +85 -0
@@ -0,0 +1,865 @@
1
+ // The policy registry: namespaces, their keys, the pushed files and the org's
2
+ // guardrails, kept in the registry's tables (`registry.schema.ts`) through Better
3
+ // Auth's database adapter. One org per server: the namespaces and the guardrails
4
+ // file are the server's own.
5
+ //
6
+ // - A revision is a file in its canonical text under its hash (`policiesHash`, and
7
+ // for the org's guardrails file, `guardrailsHash`). It
8
+ // is written once and never updated: a push of a file the registry has already
9
+ // writes no revision, it only moves the head back to it. A revision read back is
10
+ // hashed again, and one that no longer matches its hash is never served.
11
+ // - A namespace's head is its current revision; the org's guardrails have one too.
12
+ // A push checks ownership against the owners table, claims the Resources it names
13
+ // as its own, writes the revision, moves the head and logs the push in one
14
+ // transaction; the owners table's unique Resource name refuses a second claim
15
+ // made at the same time.
16
+ // - Keys are stored as hashes (`registry.key.ts`), one live key per namespace and
17
+ // kind. A refusal never quotes the key, and answers a key of another namespace as
18
+ // it answers a namespace that does not exist.
19
+ //
20
+ // The operator's half (namespaces, keys, the guardrails file) is a programmatic API
21
+ // on the instance, `access.registry`; the services' half (push, and the read-only
22
+ // reads a pull makes) is served over HTTP by `registry.endpoints.ts`.
23
+ import { AccessError } from "../access/access.error.js";
24
+ import { checkGuardrails } from "../guardrails/guardrails.compiler.js";
25
+ import { canonicalGuardrails } from "../guardrails/guardrails.hash.js";
26
+ import { checkPolicies, isName, isNamespace } from "../policies.compiler.js";
27
+ import { canonicalPolicies } from "../policies.hash.js";
28
+ import { sha256Hex } from "../sha256.digest.js";
29
+ import { beaconRolledBack, checkPushedBeacons, pushedOf, storedBeaconHash, } from "./beacon.ledger.js";
30
+ import { checkForeignMarkers, checkGuardrailNamespaces, checkGuardrailsOwnership, checkNamespaceOwnership, foreignNamespaces, ownResources, resourcesNamed, } from "./ownership.check.js";
31
+ import { KEY_KINDS, keyHashOf, newKey, } from "./registry.key.js";
32
+ import { AccessRegistryRefusal } from "./registry.refusal.js";
33
+ import { REGISTRY_MODELS } from "./registry.schema.js";
34
+ export { AccessRegistryRefusal } from "./registry.refusal.js";
35
+ /**
36
+ * The most rows one list reads. A list that would be longer is refused, naming
37
+ * what is too long: never cut short.
38
+ */
39
+ const MANY = 10_000;
40
+ /** A revision's hash, as a route names it. */
41
+ const HASH = /^sha256:[0-9a-f]{64}$/;
42
+ /** The largest policies file a push stores, in bytes of its JSON text: 1 MiB. */
43
+ export const MAX_POLICIES_BYTES = 1_048_576;
44
+ /** How long the push log adds refusals of no key of this registry into one row. */
45
+ const REFUSALS_WINDOW_MS = 60_000;
46
+ /** How long a refusal of no key of this registry waits, counted in memory, before its row's count is written. */
47
+ const REFUSALS_FLUSH_MS = 1_000;
48
+ /** How often `revokeKey` and `assignResource` run again when another call at the same time took a unique slot first (`rotateKey` runs once: see there). */
49
+ const KEY_ATTEMPTS = 3;
50
+ /** The most Resources one foreign read names. */
51
+ const MAX_FOREIGN_RESOURCES = 100;
52
+ const NOT_A_KEY = 401;
53
+ const NOT_ALLOWED = 403;
54
+ const namespaceLine = (namespace) => `namespace:${namespace}`;
55
+ const GUARDRAILS_LINE = "guardrails";
56
+ const by = (field, value) => [{ field, value }];
57
+ const among = (field, values) => [
58
+ { field, operator: "in", value: [...values] },
59
+ ];
60
+ const unique = (values) => {
61
+ const seen = new Set();
62
+ const kept = [];
63
+ for (const value of values) {
64
+ if (!seen.has(value)) {
65
+ seen.add(value);
66
+ kept.push(value);
67
+ }
68
+ }
69
+ return kept;
70
+ };
71
+ /** The bytes of a pushed body's JSON text; `undefined` when it has none (too deep, a cycle). */
72
+ const bytesOf = (body) => {
73
+ try {
74
+ const text = JSON.stringify(body);
75
+ return text === undefined ? 0 : new TextEncoder().encode(text).length;
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ };
81
+ /**
82
+ * Runs `run` again when it failed on something other than a refusal: another call at
83
+ * the same time took a unique slot first, and the next attempt sees what it wrote.
84
+ * Past `KEY_ATTEMPTS` tries it throws `exhausted`'s refusal, naming the call and the
85
+ * last failure, never the database's error alone.
86
+ */
87
+ const retried = async (run, exhausted) => {
88
+ for (let attempt = 1;; attempt += 1) {
89
+ try {
90
+ return await run();
91
+ }
92
+ catch (error) {
93
+ if (error instanceof AccessError) {
94
+ throw error;
95
+ }
96
+ if (attempt >= KEY_ATTEMPTS) {
97
+ throw exhausted(error instanceof Error ? error.message : String(error));
98
+ }
99
+ }
100
+ }
101
+ };
102
+ /** A key call that failed `KEY_ATTEMPTS` times. */
103
+ const keyCallFailed = (call) => (last) => new AccessError("ACCESS_KEY_REFUSED", `${call} failed ${KEY_ATTEMPTS} times: other key calls on the same namespace at the same time, or the database refused the write (last: ${last}). Run it again`);
104
+ /** A pushed file, checked as the engine would check it, in its canonical text and hash. */
105
+ const canonical = (file, check, text) => {
106
+ try {
107
+ check(file);
108
+ }
109
+ catch (error) {
110
+ throw new AccessError("ACCESS_POLICIES_INVALID", String(error.message).replace(/^Aventara Access: /, ""));
111
+ }
112
+ const written = text(file);
113
+ return {
114
+ format: file.format,
115
+ text: written,
116
+ hash: `sha256:${sha256Hex(written)}`,
117
+ };
118
+ };
119
+ /** A stored revision, after checking it still hashes to the hash it is stored under. */
120
+ const served = (row) => {
121
+ if (`sha256:${sha256Hex(row.file)}` !== row.hash) {
122
+ throw new AccessRegistryRefusal("ACCESS_REVISION_CHANGED", `the stored revision ${row.hash} was changed outside the registry: its text no longer hashes to its hash, and the registry never updates a revision. Push the file again from its source`, 500);
123
+ }
124
+ return {
125
+ hash: row.hash,
126
+ format: row.format,
127
+ createdAt: row.createdAt,
128
+ file: JSON.parse(row.file),
129
+ };
130
+ };
131
+ /** The registry over the adapter `adapterOf` resolves (Better Auth's, once it is built). */
132
+ export const createRegistry = (adapterOf, settings = { ownership: "first-push" }) => {
133
+ /** Up to `MANY` rows of a list; a longer one is refused with `tooMany`, never cut short. */
134
+ const atMost = async (adapter, input, tooMany) => {
135
+ const rows = await adapter.findMany({ ...input, limit: MANY + 1 });
136
+ if (rows.length > MANY) {
137
+ throw new AccessRegistryRefusal("ACCESS_NAMESPACE", tooMany, 500);
138
+ }
139
+ return rows;
140
+ };
141
+ const namespaceNamed = async (adapter, name) => {
142
+ const row = isNamespace(name)
143
+ ? await adapter.findOne({
144
+ model: REGISTRY_MODELS.namespace,
145
+ where: by("name", name),
146
+ })
147
+ : null;
148
+ if (row === null) {
149
+ throw new AccessError("ACCESS_NAMESPACE", `the registry has no namespace ${JSON.stringify(name)}; createNamespace adds one`);
150
+ }
151
+ return row;
152
+ };
153
+ /** The namespaces among `names` the registry has. */
154
+ const namespacesAmong = async (adapter, names) => {
155
+ const wanted = unique(names.filter(isNamespace));
156
+ if (wanted.length === 0) {
157
+ return new Set();
158
+ }
159
+ const rows = await adapter.findMany({
160
+ model: REGISTRY_MODELS.namespace,
161
+ where: among("name", wanted),
162
+ limit: wanted.length,
163
+ });
164
+ return new Set(rows.map((row) => row.name));
165
+ };
166
+ const checkKind = (kind) => {
167
+ if (!KEY_KINDS.includes(kind)) {
168
+ throw new AccessError("ACCESS_OPTIONS_INVALID", `a key's kind is "push" or "pull"; it was ${JSON.stringify(kind)}`);
169
+ }
170
+ return kind;
171
+ };
172
+ /** The one slot a namespace's live key of `kind` holds. */
173
+ const slotOf = (namespaceId, kind) => `${namespaceId}:${kind}`;
174
+ const revoke = async (adapter, namespaceId, kind) => {
175
+ const live = await adapter.findOne({
176
+ model: REGISTRY_MODELS.key,
177
+ where: by("slot", slotOf(namespaceId, kind)),
178
+ });
179
+ if (live !== null) {
180
+ await adapter.update({
181
+ model: REGISTRY_MODELS.key,
182
+ where: by("id", live.id),
183
+ update: { revokedAt: new Date(), slot: null },
184
+ });
185
+ }
186
+ };
187
+ const issue = async (adapter, namespaceId, kind) => {
188
+ const key = newKey(kind);
189
+ await adapter.create({
190
+ model: REGISTRY_MODELS.key,
191
+ data: {
192
+ namespaceId,
193
+ kind,
194
+ keyHash: keyHashOf(key),
195
+ slot: slotOf(namespaceId, kind),
196
+ },
197
+ });
198
+ return key;
199
+ };
200
+ /** The key row `key` names, or `null` for text that is no key of this registry. */
201
+ const keyRowOf = async (adapter, key) => {
202
+ const hash = keyHashOf(key ?? "");
203
+ return hash === undefined
204
+ ? null
205
+ : adapter.findOne({
206
+ model: REGISTRY_MODELS.key,
207
+ where: by("keyHash", hash),
208
+ });
209
+ };
210
+ /** The key and the namespace a request names, as far as the registry has them. */
211
+ const lookUp = async (adapter, namespace, key) => ({
212
+ key: await keyRowOf(adapter, key),
213
+ namespace: isNamespace(namespace)
214
+ ? await adapter.findOne({
215
+ model: REGISTRY_MODELS.namespace,
216
+ where: by("name", namespace),
217
+ })
218
+ : null,
219
+ });
220
+ /**
221
+ * The namespace a key opens for `kinds`, or a refusal that names what is wrong
222
+ * with the key and never the key itself. A key of another namespace is answered
223
+ * as a namespace the registry does not have: neither tells the caller which
224
+ * namespaces exist.
225
+ */
226
+ const opens = (namespace, key, found, kinds) => {
227
+ const { key: row, namespace: owner } = found;
228
+ if (row === null || owner === null || row.namespaceId !== owner.id) {
229
+ throw new AccessRegistryRefusal("ACCESS_KEY_REFUSED", key === undefined || key === ""
230
+ ? "the request carries no registry key: send the namespace's key in the x-access-key header"
231
+ : `the x-access-key header holds no key of namespace ${JSON.stringify(namespace)} in this registry`, NOT_A_KEY);
232
+ }
233
+ if (row.revokedAt != null) {
234
+ throw new AccessRegistryRefusal("ACCESS_KEY_REFUSED", `the ${row.kind} key in the x-access-key header was revoked or rotated out; ask the operator for the namespace's current one`, NOT_A_KEY);
235
+ }
236
+ if (!kinds.includes(row.kind)) {
237
+ throw new AccessRegistryRefusal("ACCESS_KEY_REFUSED", `a ${row.kind} key only reads; a push takes namespace "${namespace}"'s push key`, NOT_ALLOWED);
238
+ }
239
+ return { namespace: owner, key: row };
240
+ };
241
+ const authorize = async (adapter, namespace, key, kinds) => opens(namespace, key, await lookUp(adapter, namespace, key), kinds);
242
+ /** The owner rows of `resources`, and who owns each by namespace name. */
243
+ const ownersOf = async (adapter, resources) => {
244
+ const wanted = unique(resources);
245
+ if (wanted.length === 0) {
246
+ return { rows: [], owners: new Map() };
247
+ }
248
+ const rows = await adapter.findMany({
249
+ model: REGISTRY_MODELS.owner,
250
+ where: among("resource", wanted),
251
+ limit: wanted.length,
252
+ });
253
+ const ids = unique(rows.map((row) => row.namespaceId));
254
+ const namespaces = ids.length === 0
255
+ ? []
256
+ : await adapter.findMany({
257
+ model: REGISTRY_MODELS.namespace,
258
+ where: among("id", ids),
259
+ limit: ids.length,
260
+ });
261
+ const names = new Map();
262
+ for (const row of namespaces) {
263
+ names.set(row.id, row.name);
264
+ }
265
+ const owners = new Map();
266
+ for (const row of rows) {
267
+ owners.set(row.resource, names.get(row.namespaceId) ?? "");
268
+ }
269
+ return { rows, owners };
270
+ };
271
+ /**
272
+ * Checks ownership of a file pushed to `namespace` against what `adapter` reads
273
+ * now, but for its foreign markers (`checkMarkers`), and answers what it read:
274
+ * the owner rows, and who owns each Resource the file names.
275
+ */
276
+ const checkOwnership = async (adapter, namespace, pushed) => {
277
+ const read = await ownersOf(adapter, resourcesNamed(pushed.file));
278
+ checkNamespaceOwnership(namespace, pushed.file, read.owners, settings.ownership);
279
+ return read;
280
+ };
281
+ /**
282
+ * Checks a push's beacons, then its file's foreign markers. In that order: a
283
+ * service that cannot show it shares a marked namespace's database gets the one
284
+ * beacon refusal, whatever that namespace is or owns, so it learns nothing of who
285
+ * owns what.
286
+ */
287
+ const checkMarkers = async (adapter, namespace, pushed, owners) => {
288
+ const marked = foreignNamespaces(pushed.file);
289
+ checkPushedBeacons(namespace, marked, pushed.beacons, await beaconsOf(adapter, marked));
290
+ checkForeignMarkers(namespace, pushed.file, owners, await namespacesAmong(adapter, marked));
291
+ };
292
+ /**
293
+ * With first-push ownership: claims the Resources `file` names as its own that no
294
+ * namespace owns yet, and gives back those this namespace claimed by a push and
295
+ * its new file no longer names. A claim another push made at the same time fails
296
+ * the owners table's unique Resource name, and with it the transaction.
297
+ */
298
+ const claim = async (adapter, namespaceId, file, known) => {
299
+ const own = new Set(ownResources(file));
300
+ const owned = new Set(known.map((row) => row.resource));
301
+ for (const resource of own) {
302
+ if (!owned.has(resource)) {
303
+ await adapter.create({
304
+ model: REGISTRY_MODELS.owner,
305
+ data: { resource, namespaceId, source: "push" },
306
+ });
307
+ }
308
+ }
309
+ const held = await atMost(adapter, {
310
+ model: REGISTRY_MODELS.owner,
311
+ where: [...by("namespaceId", namespaceId), ...by("source", "push")],
312
+ }, `a namespace holds more than ${MANY} Resources claimed by its pushes, more than the registry reads in one list`);
313
+ for (const row of held) {
314
+ if (!own.has(row.resource)) {
315
+ await adapter.delete({
316
+ model: REGISTRY_MODELS.owner,
317
+ where: by("id", row.id),
318
+ });
319
+ }
320
+ }
321
+ };
322
+ const setHead = async (adapter, line, hash) => {
323
+ const current = await adapter.findOne({
324
+ model: REGISTRY_MODELS.head,
325
+ where: by("line", line),
326
+ });
327
+ if (current === null) {
328
+ await adapter.create({
329
+ model: REGISTRY_MODELS.head,
330
+ data: { line, hash, updatedAt: new Date() },
331
+ });
332
+ }
333
+ else if (current.hash !== hash) {
334
+ await adapter.update({
335
+ model: REGISTRY_MODELS.head,
336
+ where: by("id", current.id),
337
+ update: { hash, updatedAt: new Date() },
338
+ });
339
+ }
340
+ };
341
+ const log = (adapter, entry) => adapter.create({
342
+ model: REGISTRY_MODELS.pushLog,
343
+ data: {
344
+ line: entry.line,
345
+ keyId: entry.keyId ?? null,
346
+ hash: entry.hash ?? null,
347
+ outcome: entry.outcome,
348
+ code: entry.code ?? null,
349
+ count: 1,
350
+ },
351
+ });
352
+ const unkeyed = new Map();
353
+ /** Writes an open row's count once `REFUSALS_FLUSH_MS` has passed; a failed write is dropped (the row is a log). */
354
+ const flushLater = (adapter, open) => {
355
+ if (open.flush !== undefined) {
356
+ return;
357
+ }
358
+ open.flush = setTimeout(() => {
359
+ open.flush = undefined;
360
+ open.id
361
+ .then((id) => adapter.update({
362
+ model: REGISTRY_MODELS.pushLog,
363
+ where: by("id", id),
364
+ update: { count: open.count },
365
+ }))
366
+ .catch(() => undefined);
367
+ }, REFUSALS_FLUSH_MS);
368
+ open.flush.unref?.();
369
+ };
370
+ const countUnkeyed = async (adapter, line) => {
371
+ const now = Date.now();
372
+ const open = unkeyed.get(line);
373
+ if (open !== undefined && now < open.until) {
374
+ open.count += 1;
375
+ flushLater(adapter, open);
376
+ return;
377
+ }
378
+ const id = log(adapter, {
379
+ line,
380
+ outcome: "refused",
381
+ code: "ACCESS_KEY_REFUSED",
382
+ }).then((row) => row.id);
383
+ unkeyed.set(line, {
384
+ id,
385
+ until: now + REFUSALS_WINDOW_MS,
386
+ count: 1,
387
+ flush: undefined,
388
+ });
389
+ await id;
390
+ };
391
+ /** Stores a checked file as a revision of `model` (unless it is there), moves `line`'s head to it and logs the push. */
392
+ const store = async (adapter, model, line, file, extra, keyId) => {
393
+ const existing = await adapter.findOne({
394
+ model,
395
+ where: by("hash", file.hash),
396
+ });
397
+ if (existing === null) {
398
+ await adapter.create({
399
+ model,
400
+ data: {
401
+ ...extra,
402
+ hash: file.hash,
403
+ format: file.format,
404
+ file: file.text,
405
+ },
406
+ });
407
+ }
408
+ await setHead(adapter, line, file.hash);
409
+ await log(adapter, { line, keyId, hash: file.hash, outcome: "accepted" });
410
+ return { hash: file.hash, created: existing === null };
411
+ };
412
+ /** A namespace a key opens, then what `read` answers for it. */
413
+ const keyed = (read) => async (namespace, key, ...args) => {
414
+ const adapter = await adapterOf();
415
+ const opened = await authorize(adapter, namespace, key, ["pull", "push"]);
416
+ return read(adapter, opened.namespace, ...args);
417
+ };
418
+ /** The revision of `model` stored under `hash` (of the namespace `namespaceId`, for a policies revision). */
419
+ const revisionOf = async (adapter, model, hash, namespaceId) => {
420
+ const row = await adapter.findOne({
421
+ model,
422
+ where: [
423
+ ...by("hash", hash),
424
+ ...(namespaceId === undefined ? [] : by("namespaceId", namespaceId)),
425
+ ],
426
+ });
427
+ return row === null ? null : served(row);
428
+ };
429
+ const headOf = async (adapter, line) => (await adapter.findOne({
430
+ model: REGISTRY_MODELS.head,
431
+ where: by("line", line),
432
+ }))?.hash ?? null;
433
+ const checkResource = (resource) => {
434
+ if (!isName(resource)) {
435
+ throw new AccessError("ACCESS_OWNERSHIP", `${JSON.stringify(resource)} is not a Resource name (a letter or "_", then letters, digits or "_")`);
436
+ }
437
+ return resource;
438
+ };
439
+ /** The current beacon hash of each of `names` (`null`: none, or no such namespace). */
440
+ const beaconsOf = async (adapter, names) => {
441
+ const found = new Map();
442
+ for (const name of names) {
443
+ found.set(name, null);
444
+ }
445
+ const wanted = unique(names.filter(isNamespace));
446
+ if (wanted.length === 0) {
447
+ return found;
448
+ }
449
+ const namespaces = await adapter.findMany({
450
+ model: REGISTRY_MODELS.namespace,
451
+ where: among("name", wanted),
452
+ limit: wanted.length,
453
+ });
454
+ const rows = namespaces.length === 0
455
+ ? []
456
+ : await adapter.findMany({
457
+ model: REGISTRY_MODELS.beacon,
458
+ where: among("slot", namespaces.map((row) => row.id)),
459
+ limit: namespaces.length,
460
+ });
461
+ for (const row of namespaces) {
462
+ found.set(row.name, rows.find((beacon) => beacon.slot === row.id)?.beaconHash ?? null);
463
+ }
464
+ return found;
465
+ };
466
+ /**
467
+ * Keeps the pushing namespace's own beacon when the push carries one (the push
468
+ * checked its beacons against what the registry keeps first): only a
469
+ * namespace's own push registers or replaces its beacon, and a reader only
470
+ * refers to an owner's. A new one is added; a changed one retires the current
471
+ * one and takes its slot; one the namespace pushed before and replaced is a
472
+ * rollback, refused.
473
+ */
474
+ const settleBeacons = async (adapter, namespace, beacons) => {
475
+ const own = beacons[namespace.name];
476
+ if (own === undefined) {
477
+ return;
478
+ }
479
+ const beaconHash = storedBeaconHash(namespace.name, own);
480
+ const seen = await adapter.findOne({
481
+ model: REGISTRY_MODELS.beacon,
482
+ where: by("beaconHash", beaconHash),
483
+ });
484
+ if (seen !== null) {
485
+ // Bound to the namespace, a hash is only ever this namespace's.
486
+ if (seen.slot === namespace.id) {
487
+ return;
488
+ }
489
+ beaconRolledBack(namespace.name);
490
+ }
491
+ const current = await adapter.findOne({
492
+ model: REGISTRY_MODELS.beacon,
493
+ where: by("slot", namespace.id),
494
+ });
495
+ if (current !== null) {
496
+ await adapter.update({
497
+ model: REGISTRY_MODELS.beacon,
498
+ where: by("id", current.id),
499
+ update: { slot: null, retiredAt: new Date() },
500
+ });
501
+ }
502
+ await adapter.create({
503
+ model: REGISTRY_MODELS.beacon,
504
+ data: { namespaceId: namespace.id, beaconHash, slot: namespace.id },
505
+ });
506
+ };
507
+ /** The one refusal of a foreign read that is not the namespace's to make. */
508
+ const notRead = (namespace) => {
509
+ throw new AccessRegistryRefusal("ACCESS_OWNERSHIP", `namespace "${namespace}"'s current file does not read that as foreign: push a file whose schema marks each Resource it reads { "foreign": "<its owner>" } first, then pull its owner's facts`, 404);
510
+ };
511
+ /** A foreign read, its request held to the namespace's current file (see `foreign`). */
512
+ const foreignRead = async (adapter, row, owner, names) => {
513
+ if (names.length > MAX_FOREIGN_RESOURCES ||
514
+ !isNamespace(owner) ||
515
+ owner === row.name) {
516
+ return notRead(row.name);
517
+ }
518
+ const ownHash = await headOf(adapter, namespaceLine(row.name));
519
+ const own = ownHash === null
520
+ ? null
521
+ : await revisionOf(adapter, REGISTRY_MODELS.policyRevision, ownHash, row.id);
522
+ const read = new Set(Object.entries(own?.file.schema ?? {})
523
+ .filter(([, facts]) => facts.foreign === owner)
524
+ .map(([resource]) => resource));
525
+ if (read.size === 0 || names.some((resource) => !read.has(resource))) {
526
+ return notRead(row.name);
527
+ }
528
+ const ownerRow = await adapter.findOne({
529
+ model: REGISTRY_MODELS.namespace,
530
+ where: by("name", owner),
531
+ });
532
+ if (ownerRow === null) {
533
+ return notRead(row.name);
534
+ }
535
+ const facts = {};
536
+ if (names.length > 0) {
537
+ const { owners } = await ownersOf(adapter, names);
538
+ const ownerHash = await headOf(adapter, namespaceLine(owner));
539
+ const head = ownerHash === null
540
+ ? null
541
+ : await revisionOf(adapter, REGISTRY_MODELS.policyRevision, ownerHash, ownerRow.id);
542
+ for (const resource of names) {
543
+ const entry = head?.file.schema[resource];
544
+ if (owners.get(resource) !== owner ||
545
+ entry === undefined ||
546
+ entry.foreign !== undefined) {
547
+ return notRead(row.name);
548
+ }
549
+ facts[resource] = entry;
550
+ }
551
+ }
552
+ return {
553
+ owner,
554
+ beacon: (await beaconsOf(adapter, [owner])).get(owner) ?? null,
555
+ resources: facts,
556
+ };
557
+ };
558
+ /**
559
+ * The namespace and key a push opens, or its refusal. A refusal of a key the
560
+ * registry has is logged with that key. One of no key of this registry is only
561
+ * counted, and only for a namespace it has: no caller without a key writes a row
562
+ * of its own.
563
+ */
564
+ const admitted = async (adapter, namespace, key) => {
565
+ const found = await lookUp(adapter, namespace, key);
566
+ try {
567
+ return opens(namespace, key, found, ["push"]);
568
+ }
569
+ catch (error) {
570
+ if (error instanceof AccessError) {
571
+ const line = namespaceLine(namespace);
572
+ if (found.key !== null) {
573
+ await log(adapter, {
574
+ line,
575
+ keyId: found.key.id,
576
+ outcome: "refused",
577
+ code: error.code,
578
+ });
579
+ }
580
+ else if (found.namespace !== null) {
581
+ await countUnkeyed(adapter, line);
582
+ }
583
+ }
584
+ throw error;
585
+ }
586
+ };
587
+ const operator = {
588
+ createNamespace: async (name) => {
589
+ if (!isNamespace(name)) {
590
+ throw new AccessError("ACCESS_NAMESPACE", `${JSON.stringify(name)} is not a namespace name (a lowercase letter, then lowercase letters, digits, "_" or "-"; at most 63 characters)`);
591
+ }
592
+ const adapter = await adapterOf();
593
+ return adapter.transaction(async (trx) => {
594
+ const existing = await trx.findOne({
595
+ model: REGISTRY_MODELS.namespace,
596
+ where: by("name", name),
597
+ });
598
+ if (existing !== null) {
599
+ throw new AccessError("ACCESS_NAMESPACE", `the registry has namespace "${name}" already; rotateKey issues it new keys`);
600
+ }
601
+ const row = await trx.create({
602
+ model: REGISTRY_MODELS.namespace,
603
+ data: { name },
604
+ });
605
+ return {
606
+ namespace: name,
607
+ push: await issue(trx, row.id, "push"),
608
+ pull: await issue(trx, row.id, "pull"),
609
+ };
610
+ });
611
+ },
612
+ rotateKey: async (namespace, kind) => {
613
+ const checked = checkKind(kind);
614
+ const adapter = await adapterOf();
615
+ const call = `rotateKey(${JSON.stringify(namespace)}, "${checked}")`;
616
+ const overtaken = () => {
617
+ throw new AccessError("ACCESS_KEY_REFUSED", `another rotateKey or revokeKey of namespace ${JSON.stringify(namespace)}'s ${checked} key ran at the same time as this ${call}, so the key it would issue is not the live one and is not shown: the other call's answer stands. Run rotateKey again for a new key`);
618
+ };
619
+ const slotHolder = async () => {
620
+ const row = await namespaceNamed(adapter, namespace);
621
+ return ((await adapter.findOne({
622
+ model: REGISTRY_MODELS.key,
623
+ where: by("slot", slotOf(row.id, checked)),
624
+ }))?.id ?? null);
625
+ };
626
+ // One try: a rotation that lost the key's slot to another one at the same time
627
+ // is refused rather than run again, since running it again would revoke the key
628
+ // the other call has just handed back.
629
+ const before = await slotHolder();
630
+ let key;
631
+ try {
632
+ key = await adapter.transaction(async (trx) => {
633
+ const row = await namespaceNamed(trx, namespace);
634
+ await revoke(trx, row.id, checked);
635
+ return issue(trx, row.id, checked);
636
+ });
637
+ }
638
+ catch (error) {
639
+ if (error instanceof AccessError) {
640
+ throw error;
641
+ }
642
+ const after = await slotHolder().catch(() => before);
643
+ if (after !== null && after !== before) {
644
+ overtaken();
645
+ }
646
+ throw new AccessError("ACCESS_KEY_REFUSED", `${call} did not take the key's slot: the database refused the write (${error instanceof Error ? error.message : String(error)}). Run it again`);
647
+ }
648
+ // Another call that committed after this one replaced the key it issued:
649
+ // handing it back would show the operator a key that no longer opens anything.
650
+ const issued = await keyRowOf(adapter, key);
651
+ if (issued === null || issued.revokedAt != null) {
652
+ overtaken();
653
+ }
654
+ return key;
655
+ },
656
+ revokeKey: async (namespace, kind) => {
657
+ const checked = checkKind(kind);
658
+ const adapter = await adapterOf();
659
+ await retried(() => adapter.transaction(async (trx) => revoke(trx, (await namespaceNamed(trx, namespace)).id, checked)), keyCallFailed(`revokeKey(${JSON.stringify(namespace)}, "${checked}")`));
660
+ },
661
+ pushGuardrails: async (file) => {
662
+ const adapter = await adapterOf();
663
+ try {
664
+ checkGuardrailsOwnership(file);
665
+ const checked = canonical(file, checkGuardrails, canonicalGuardrails);
666
+ checkGuardrailNamespaces(file, await namespacesAmong(adapter, file.denies.flatMap((guardrail) => guardrail.resources === "*"
667
+ ? []
668
+ : Object.keys(guardrail.resources))));
669
+ return await adapter.transaction((trx) => store(trx, REGISTRY_MODELS.guardrailRevision, GUARDRAILS_LINE, checked, {}, undefined));
670
+ }
671
+ catch (error) {
672
+ if (error instanceof AccessError) {
673
+ await log(adapter, {
674
+ line: GUARDRAILS_LINE,
675
+ outcome: "refused",
676
+ code: error.code,
677
+ });
678
+ }
679
+ throw error;
680
+ }
681
+ },
682
+ assignResource: async (resource, namespace) => {
683
+ const checked = checkResource(resource);
684
+ const adapter = await adapterOf();
685
+ await retried(() => adapter.transaction(async (trx) => {
686
+ const row = await namespaceNamed(trx, namespace);
687
+ const current = await trx.findOne({
688
+ model: REGISTRY_MODELS.owner,
689
+ where: by("resource", checked),
690
+ });
691
+ if (current === null) {
692
+ await trx.create({
693
+ model: REGISTRY_MODELS.owner,
694
+ data: {
695
+ resource: checked,
696
+ namespaceId: row.id,
697
+ source: "operator",
698
+ },
699
+ });
700
+ }
701
+ else if (current.namespaceId !== row.id ||
702
+ current.source !== "operator") {
703
+ await trx.update({
704
+ model: REGISTRY_MODELS.owner,
705
+ where: by("id", current.id),
706
+ update: { namespaceId: row.id, source: "operator" },
707
+ });
708
+ }
709
+ }), (last) => new AccessError("ACCESS_OWNERSHIP", `assignResource(${JSON.stringify(checked)}, ${JSON.stringify(namespace)}) did not take the Resource in ${KEY_ATTEMPTS} tries: other calls on it at the same time took it each time, or the database refused the write (last: ${last}). Run it again`));
710
+ },
711
+ releaseResource: async (resource) => {
712
+ const checked = checkResource(resource);
713
+ const adapter = await adapterOf();
714
+ await adapter.delete({
715
+ model: REGISTRY_MODELS.owner,
716
+ where: by("resource", checked),
717
+ });
718
+ },
719
+ };
720
+ return {
721
+ operator,
722
+ /**
723
+ * The namespace a key belongs to, and its kind: what `avaccess pull` reads when
724
+ * the project has no lock yet. A key that is not one of this registry's, or was
725
+ * revoked, is refused as a read with it would be.
726
+ */
727
+ keyOwner: async (key) => {
728
+ const adapter = await adapterOf();
729
+ const row = await keyRowOf(adapter, key);
730
+ const owner = row === null
731
+ ? null
732
+ : await adapter.findOne({
733
+ model: REGISTRY_MODELS.namespace,
734
+ where: by("id", row.namespaceId),
735
+ });
736
+ if (row === null || owner === null) {
737
+ throw new AccessRegistryRefusal("ACCESS_KEY_REFUSED", key === undefined || key === ""
738
+ ? "the request carries no registry key: send the namespace's key in the x-access-key header"
739
+ : "the x-access-key header holds no key of this registry", NOT_A_KEY);
740
+ }
741
+ if (row.revokedAt != null) {
742
+ throw new AccessRegistryRefusal("ACCESS_KEY_REFUSED", `the ${row.kind} key in the x-access-key header was revoked or rotated out; ask the operator for the namespace's current one`, NOT_A_KEY);
743
+ }
744
+ return { namespace: owner.name, kind: row.kind };
745
+ },
746
+ /**
747
+ * Whether `key` is `namespace`'s push key: the refusal a push would get
748
+ * (`ACCESS_KEY_REFUSED`, logged as a push's), or nothing. The routes' mounts ask
749
+ * it before a push's body is read.
750
+ */
751
+ admitPush: async (namespace, key) => {
752
+ await admitted(await adapterOf(), namespace, key);
753
+ },
754
+ /** Pushes `body` as `namespace`'s file, with that namespace's push key. */
755
+ push: async (namespace, key, body) => {
756
+ const adapter = await adapterOf();
757
+ const line = namespaceLine(namespace);
758
+ const opened = await admitted(adapter, namespace, key);
759
+ try {
760
+ const bytes = bytesOf(body);
761
+ if (bytes === undefined || bytes > MAX_POLICIES_BYTES) {
762
+ throw new AccessRegistryRefusal("ACCESS_POLICIES_INVALID", bytes === undefined
763
+ ? "the pushed body is no JSON a policies file is written in"
764
+ : `the pushed file is ${bytes} bytes; the registry takes a policies file of at most ${MAX_POLICIES_BYTES} bytes (1 MiB)`, bytes === undefined ? 400 : 413);
765
+ }
766
+ const pushed = pushedOf(body);
767
+ const stored = () => adapter.transaction(async (inside) => {
768
+ const known = await checkOwnership(inside, namespace, pushed);
769
+ const file = canonical(pushed.file, checkPolicies, canonicalPolicies);
770
+ await checkMarkers(inside, namespace, pushed, known.owners);
771
+ await settleBeacons(inside, opened.namespace, pushed.beacons);
772
+ if (settings.ownership === "first-push") {
773
+ await claim(inside, opened.namespace.id, pushed.file, known.rows);
774
+ }
775
+ return store(inside, REGISTRY_MODELS.policyRevision, line, file, { namespaceId: opened.namespace.id }, opened.key.id);
776
+ });
777
+ try {
778
+ return await stored();
779
+ }
780
+ catch (error) {
781
+ if (error instanceof AccessError) {
782
+ throw error;
783
+ }
784
+ // A push at the same time claimed a Resource this file names, and the
785
+ // owners table refused the second claim: checked again against what it
786
+ // committed, this push is refused as any claim of an owned Resource is.
787
+ const { owners } = await checkOwnership(adapter, namespace, pushed);
788
+ await checkMarkers(adapter, namespace, pushed, owners);
789
+ throw error;
790
+ }
791
+ }
792
+ catch (error) {
793
+ if (error instanceof AccessError) {
794
+ await log(adapter, {
795
+ line,
796
+ keyId: opened.key.id,
797
+ outcome: "refused",
798
+ code: error.code,
799
+ });
800
+ }
801
+ throw error;
802
+ }
803
+ },
804
+ namespace: keyed(async (adapter, row) => ({
805
+ name: row.name,
806
+ createdAt: row.createdAt,
807
+ head: await headOf(adapter, namespaceLine(row.name)),
808
+ beacon: (await beaconsOf(adapter, [row.name])).get(row.name) ?? null,
809
+ })),
810
+ revisions: keyed(async (adapter, row) => (await atMost(adapter, {
811
+ model: REGISTRY_MODELS.policyRevision,
812
+ where: by("namespaceId", row.id),
813
+ sortBy: { field: "createdAt", direction: "desc" },
814
+ }, `namespace "${row.name}" has more than ${MANY} revisions, more than the registry lists at once`)).map(({ hash, format, createdAt }) => ({ hash, format, createdAt }))),
815
+ revision: keyed(async (adapter, row, hash) => HASH.test(hash)
816
+ ? revisionOf(adapter, REGISTRY_MODELS.policyRevision, hash, row.id)
817
+ : null),
818
+ head: keyed(async (adapter, row) => {
819
+ const hash = await headOf(adapter, namespaceLine(row.name));
820
+ return hash === null
821
+ ? null
822
+ : revisionOf(adapter, REGISTRY_MODELS.policyRevision, hash, row.id);
823
+ }),
824
+ /**
825
+ * What the namespace reads of `owner`: of the Resources its current file reads
826
+ * as `owner`'s (`{ "foreign": "<owner>" }`), `owner`'s schema entry of each one
827
+ * `resources` names, and the hash of the beacon `owner` pushed. Anything else
828
+ * (an owner the file does not read, a Resource it does not read as that
829
+ * owner's, an owner that does not own it or has no file describing it) gets one
830
+ * and the same refusal, which tells nothing of who owns what. Every read is
831
+ * logged with its key.
832
+ */
833
+ foreign: async (namespace, key, owner, resources) => {
834
+ const adapter = await adapterOf();
835
+ const opened = await authorize(adapter, namespace, key, ["pull", "push"]);
836
+ const line = namespaceLine(opened.namespace.name);
837
+ try {
838
+ const read = await foreignRead(adapter, opened.namespace, owner, unique(resources));
839
+ await log(adapter, {
840
+ line,
841
+ keyId: opened.key.id,
842
+ outcome: "read",
843
+ });
844
+ return read;
845
+ }
846
+ catch (error) {
847
+ if (error instanceof AccessError) {
848
+ await log(adapter, {
849
+ line,
850
+ keyId: opened.key.id,
851
+ outcome: "refused",
852
+ code: error.code,
853
+ });
854
+ }
855
+ throw error;
856
+ }
857
+ },
858
+ guardrails: keyed(async (adapter) => {
859
+ const hash = await headOf(adapter, GUARDRAILS_LINE);
860
+ return hash === null
861
+ ? null
862
+ : revisionOf(adapter, REGISTRY_MODELS.guardrailRevision, hash);
863
+ }),
864
+ };
865
+ };