@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,358 @@
1
+ // The rate limit on `access.client`'s server calls of `signIn`, `signUp` and
2
+ // `resetPassword`. Better Auth limits its routes in its router, before a route runs;
3
+ // a server call to Better Auth's API skips the router, and with it the limit. So a
4
+ // Server Action that signs a user in would let anyone guess passwords as fast as the
5
+ // server answers. `call` asks this limiter first.
6
+ //
7
+ // It is Better Auth's own limit, from Better Auth's `rateLimit` options: on when they
8
+ // turn it on (`enabled`, production by default), in the same storage
9
+ // (`customStorage`, `secondaryStorage`, the database's `rateLimit` table, or memory).
10
+ // It counts each call in two buckets, and refuses it when either is full:
11
+ //
12
+ // - by client address, with the rule Better Auth's router applies to the route
13
+ // (its built-in rules: 3 tries in 10 seconds on `/sign-in/*` and `/sign-up/*`; a
14
+ // plugin's rules; `customRules`; else `window` and `max`). The address is read
15
+ // from the request's headers as Better Auth reads it (`advanced.ipAddress`: its
16
+ // headers, `trustedProxies`, `ipv6Subnet`), under the key Better Auth's router
17
+ // uses for the route, so with the database, a secondary or a custom storage a
18
+ // client's calls and its requests to the route share one budget. In memory they
19
+ // do not: Better Auth's memory store is its module's own, and this one is
20
+ // Access's. A call whose address cannot be read shares one bucket per route, as
21
+ // Better Auth's do, and none with `disableIpTracking`.
22
+ // - by target: the identifier the call names (the email, as the sign-in route
23
+ // normalizes it; a password reset's token), as the audit log's HMAC of it, so
24
+ // guesses on one account from many addresses are counted together. It counts
25
+ // failures, not tries: a call is counted when it is let through (so parallel
26
+ // guesses cannot all pass one read), and a call that succeeds clears the count,
27
+ // so failures by others never keep the account's owner out for long. Its rule is
28
+ // 10 failures, each within 5 minutes of the one before
29
+ // ({@link TARGET_FAILURES}); with `customStorage`, which can count but not
30
+ // clear, the target's count is kept in this process's memory.
31
+ //
32
+ // A refused call throws Better Auth's own 429 (`TOO_MANY_REQUESTS`, with
33
+ // `X-Retry-After`), and the audit log records it as a refused sign-in, reason
34
+ // `rate-limited`.
35
+ import { signInTarget } from "../audit/audit.log.js";
36
+ /**
37
+ * The target bucket's rule: 10 failed tries on one account, each within 5 minutes
38
+ * of the one before. Looser than the address rule, since anyone can spend it on
39
+ * someone else's account; a success clears it. NIST SP 800-63B (5.2.2) caps failed
40
+ * tries on one account at 100 before other measures; this stays well under it.
41
+ */
42
+ const TARGET_FAILURES = { window: 300, max: 10 };
43
+ /** Better Auth's built-in rules for the routes `call` reaches (better-auth 1.7.12 to 1.7.14). */
44
+ const BUILT_IN = (path) => path.startsWith("/sign-in") || path.startsWith("/sign-up")
45
+ ? { window: 10, max: 3 }
46
+ : undefined;
47
+ /**
48
+ * A `customRules` key as Better Auth matches it: `*` is any run within one path
49
+ * segment, `**` any run of segments, everything else itself.
50
+ */
51
+ const matches = (pattern, path) => {
52
+ if (!pattern.includes("*")) {
53
+ return pattern === path;
54
+ }
55
+ let source = "";
56
+ for (let at = 0; at < pattern.length; at += 1) {
57
+ const char = pattern[at];
58
+ if (char === "*" && pattern[at + 1] === "*") {
59
+ source += ".*";
60
+ at += 1;
61
+ }
62
+ else if (char === "*") {
63
+ source += "[^/]*";
64
+ }
65
+ else {
66
+ source += char.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
67
+ }
68
+ }
69
+ return new RegExp(`^${source}$`).test(path);
70
+ };
71
+ /** The first rule a plugin of the app's declares for a route, as Better Auth's router takes it. */
72
+ const pluginRule = (plugins, path) => {
73
+ for (let at = 0; plugins !== undefined && at < plugins.length; at += 1) {
74
+ const rules = plugins[at]?.rateLimit;
75
+ for (let index = 0; rules !== undefined && index < rules.length; index += 1) {
76
+ const entry = rules[index];
77
+ if (entry?.pathMatcher(path) === true) {
78
+ return { window: entry.window, max: entry.max };
79
+ }
80
+ }
81
+ }
82
+ return undefined;
83
+ };
84
+ /** The rule for a route, as Better Auth's router resolves it; `undefined` when a custom rule turns it off. */
85
+ const ruleOf = async (context, path, request) => {
86
+ let rule = BUILT_IN(path) ?? {
87
+ window: context.rateLimit.window,
88
+ max: context.rateLimit.max,
89
+ };
90
+ const plugin = pluginRule(context.options.plugins, path);
91
+ if (plugin !== undefined) {
92
+ rule = plugin;
93
+ }
94
+ const custom = context.rateLimit.customRules;
95
+ if (custom !== undefined) {
96
+ let given;
97
+ for (const pattern of Object.keys(custom)) {
98
+ if (matches(pattern, path)) {
99
+ given = custom[pattern];
100
+ break;
101
+ }
102
+ }
103
+ const resolved = typeof given === "function"
104
+ ? await given(request, rule)
105
+ : given;
106
+ if (resolved === false) {
107
+ return undefined;
108
+ }
109
+ if (resolved !== undefined && resolved !== null) {
110
+ const { window, max } = resolved;
111
+ rule = { window, max };
112
+ }
113
+ }
114
+ return rule;
115
+ };
116
+ /** Seconds until a bucket last counted at `last` opens again. */
117
+ const retryAfterOf = (last, window) => Math.ceil((last + window * 1000 - Date.now()) / 1000);
118
+ const memory = new Map();
119
+ const MEMORY_MAX_ENTRIES = 100_000;
120
+ /** How many of the oldest buckets one sweep looks at, and how many counts apart the sweeps are. */
121
+ const SWEEP = { visit: 1000, every: 100 };
122
+ let counted = 0;
123
+ /**
124
+ * Drops expired buckets from the oldest end, looking at no more than `visit` of
125
+ * them, and the oldest ones past the cap: the cost of a count stays bounded, never
126
+ * the store's size.
127
+ */
128
+ const sweep = (now, visit) => {
129
+ let over = memory.size - MEMORY_MAX_ENTRIES;
130
+ let seen = 0;
131
+ for (const key of memory.keys()) {
132
+ if (seen >= visit && over <= 0) {
133
+ return;
134
+ }
135
+ seen += 1;
136
+ const bucket = memory.get(key);
137
+ if (over > 0 || (bucket !== undefined && now >= bucket.expiresAt)) {
138
+ memory.delete(key);
139
+ over -= 1;
140
+ }
141
+ }
142
+ };
143
+ const memoryConsume = (defaultWindow) => async (key, rule) => {
144
+ const now = Date.now();
145
+ counted += 1;
146
+ sweep(now, counted % SWEEP.every === 0 ? SWEEP.visit : 0);
147
+ const entry = memory.get(key);
148
+ if (entry !== undefined && now >= entry.expiresAt) {
149
+ memory.delete(key);
150
+ }
151
+ const row = entry !== undefined && now < entry.expiresAt ? entry.row : undefined;
152
+ const expiresAt = now + (rule.window || defaultWindow) * 1000;
153
+ if (row !== undefined && now - row.lastRequest < rule.window * 1000) {
154
+ if (row.count >= rule.max) {
155
+ return {
156
+ allowed: false,
157
+ retryAfter: retryAfterOf(row.lastRequest, rule.window),
158
+ };
159
+ }
160
+ }
161
+ const count = row === undefined || now - row.lastRequest >= rule.window * 1000
162
+ ? 1
163
+ : row.count + 1;
164
+ // Set again at the end, so the order stays oldest first.
165
+ memory.delete(key);
166
+ memory.set(key, { row: { key, count, lastRequest: now }, expiresAt });
167
+ return { allowed: true, retryAfter: null };
168
+ };
169
+ /** The database storage: Better Auth's `rateLimit` table, counted as its router counts it. */
170
+ const databaseConsume = (context) => {
171
+ const db = context.adapter;
172
+ const model = "rateLimit";
173
+ const read = async (key) => {
174
+ const [row] = (await db.findMany({
175
+ model,
176
+ where: [{ field: "key", value: key }],
177
+ }));
178
+ if (row !== undefined) {
179
+ row.lastRequest = Number(row.lastRequest);
180
+ }
181
+ return row;
182
+ };
183
+ const consume = async (key, rule) => {
184
+ const windowMs = rule.window * 1000;
185
+ const row = await read(key);
186
+ const now = Date.now();
187
+ if (row === undefined) {
188
+ try {
189
+ await db.create({ model, data: { key, count: 1, lastRequest: now } });
190
+ return { allowed: true, retryAfter: null };
191
+ }
192
+ catch (error) {
193
+ if ((await read(key)) === undefined) {
194
+ throw error;
195
+ }
196
+ return consume(key, rule);
197
+ }
198
+ }
199
+ if (now - row.lastRequest >= windowMs) {
200
+ const reset = await db.incrementOne({
201
+ model,
202
+ where: [
203
+ { field: "key", value: key },
204
+ { field: "lastRequest", operator: "lte", value: row.lastRequest },
205
+ ],
206
+ increment: {},
207
+ set: { count: 1, lastRequest: now },
208
+ });
209
+ return reset ? { allowed: true, retryAfter: null } : consume(key, rule);
210
+ }
211
+ const counted = await db.incrementOne({
212
+ model,
213
+ where: [
214
+ { field: "key", value: key },
215
+ { field: "lastRequest", operator: "gt", value: now - windowMs },
216
+ { field: "count", operator: "lt", value: rule.max },
217
+ ],
218
+ increment: { count: 1 },
219
+ set: { lastRequest: now },
220
+ });
221
+ if (counted) {
222
+ return { allowed: true, retryAfter: null };
223
+ }
224
+ const fresh = await read(key);
225
+ if (fresh === undefined || now - fresh.lastRequest >= windowMs) {
226
+ return consume(key, rule);
227
+ }
228
+ return {
229
+ allowed: false,
230
+ retryAfter: retryAfterOf(fresh.lastRequest, rule.window),
231
+ };
232
+ };
233
+ return consume;
234
+ };
235
+ /** The prefix of every target bucket's key. */
236
+ const TARGET_PREFIX = "aventara-access/failures|";
237
+ /** How often, at most, one limiter prunes the database's target buckets. */
238
+ const PRUNE_EVERY_MS = 60_000;
239
+ const memoryStorage = (defaultWindow) => ({
240
+ consume: memoryConsume(defaultWindow),
241
+ clear: async (key) => memory.delete(key),
242
+ });
243
+ /**
244
+ * The storage Better Auth's options name, as its router picks it (`address`), and
245
+ * the one the target bucket counts and clears in: the same, except a custom
246
+ * storage, which cannot clear, leaves the target's count to memory.
247
+ */
248
+ const storageOf = (context) => {
249
+ const defaultWindow = context.options.rateLimit?.window ?? 10;
250
+ const custom = context.options.rateLimit?.customStorage;
251
+ if (custom !== undefined) {
252
+ return {
253
+ address: {
254
+ consume: (key, rule) => custom.consume(key, rule),
255
+ clear: async () => undefined,
256
+ },
257
+ target: memoryStorage(defaultWindow),
258
+ };
259
+ }
260
+ let storage;
261
+ if (context.rateLimit.storage === "secondary-storage") {
262
+ const secondary = context.options.secondaryStorage;
263
+ const increment = secondary?.increment;
264
+ if (secondary === undefined || increment === undefined) {
265
+ throw new TypeError("Secondary-storage rate limiting requires SecondaryStorage.increment.");
266
+ }
267
+ storage = {
268
+ consume: async (key, rule) => (await increment(key, rule.window || defaultWindow)) <= rule.max
269
+ ? { allowed: true, retryAfter: null }
270
+ : { allowed: false, retryAfter: rule.window },
271
+ clear: async (key) => secondary.delete(key),
272
+ };
273
+ }
274
+ else if (context.rateLimit.storage === "memory") {
275
+ storage = memoryStorage(defaultWindow);
276
+ }
277
+ else {
278
+ storage = {
279
+ consume: databaseConsume(context),
280
+ clear: (key) => context.adapter.deleteMany({
281
+ model: "rateLimit",
282
+ where: [{ field: "key", value: key }],
283
+ }),
284
+ // Each identifier a caller names makes a row: one that failed once and
285
+ // was never cleared would stay, so a caller naming random identifiers
286
+ // would grow the table without end.
287
+ prune: (before) => context.adapter.deleteMany({
288
+ model: "rateLimit",
289
+ where: [
290
+ { field: "key", operator: "starts_with", value: TARGET_PREFIX },
291
+ { field: "lastRequest", operator: "lt", value: before },
292
+ ],
293
+ }),
294
+ };
295
+ }
296
+ return { address: storage, target: storage };
297
+ };
298
+ /**
299
+ * The limiter: throws Better Auth's 429 when the call's address has used up the
300
+ * route's rule or its target has failed too often, and logs the refusal. Otherwise
301
+ * it answers what clears the target's count, which `call` runs once the call has
302
+ * succeeded: a refused call keeps its count.
303
+ */
304
+ export const verbRateLimiter = (input) => {
305
+ let prunedAt = Number.NEGATIVE_INFINITY;
306
+ return async (call) => {
307
+ const context = await input.context();
308
+ if (!context.rateLimit.enabled) {
309
+ return undefined;
310
+ }
311
+ const rule = await ruleOf(context, call.path, call.request);
312
+ if (rule === undefined) {
313
+ return undefined;
314
+ }
315
+ const storage = storageOf(context);
316
+ const ip = input.ipOf(call.headers, context.options);
317
+ const tracked = context.options.advanced?.ipAddress?.disableIpTracking !== true;
318
+ const target = await signInTarget(call.identifier, context.secret);
319
+ const refuse = async (retryAfter, window) => {
320
+ await input.auditLog?.record({
321
+ at: new Date(),
322
+ kind: "sign-in-failed",
323
+ userId: null,
324
+ route: call.path,
325
+ reason: "rate-limited",
326
+ origin: "client",
327
+ ...(target === undefined ? {} : { target }),
328
+ });
329
+ return new input.refusal("TOO_MANY_REQUESTS", { message: "Too many requests. Please try again later." }, { "X-Retry-After": String(retryAfter ?? window) });
330
+ };
331
+ if (ip !== null || tracked) {
332
+ // Better Auth's router's key for the route, so the two share a bucket.
333
+ const decision = await storage.address.consume(`${ip ?? "no-trusted-ip"}|${call.path}`, rule);
334
+ if (!decision.allowed) {
335
+ throw await refuse(decision.retryAfter, rule.window);
336
+ }
337
+ }
338
+ if (target === undefined) {
339
+ return undefined;
340
+ }
341
+ const now = Date.now();
342
+ if (storage.target.prune !== undefined &&
343
+ now - prunedAt >= PRUNE_EVERY_MS) {
344
+ prunedAt = now;
345
+ await storage.target
346
+ .prune(now - TARGET_FAILURES.window * 1000)
347
+ .catch(() => undefined);
348
+ }
349
+ const key = `${TARGET_PREFIX}${target}|${call.path}`;
350
+ const decision = await storage.target.consume(key, TARGET_FAILURES);
351
+ if (!decision.allowed) {
352
+ throw await refuse(decision.retryAfter, TARGET_FAILURES.window);
353
+ }
354
+ return async () => {
355
+ await storage.target.clear(key);
356
+ };
357
+ };
358
+ };
@@ -0,0 +1,30 @@
1
+ import { type AccessSchemaResourceFacts } from "../policies-contract.js";
2
+ /** The format marker of every foreign facts file. */
3
+ export declare const FOREIGN_FORMAT = "aventara-access/foreign@1";
4
+ /** The foreign facts file's name, beside the namespace's pulled policies file. */
5
+ export declare const FOREIGN_FILE = "foreign.json";
6
+ /** One foreign Resource's entry: the owner's schema facts, marked with the owner. */
7
+ export type AccessForeignEntry = AccessSchemaResourceFacts & {
8
+ readonly foreign: string;
9
+ };
10
+ /** The foreign facts file `avaccess pull --foreign` writes. */
11
+ export type AccessForeignFacts = {
12
+ /** `"aventara-access/foreign@1"`. */
13
+ readonly format: string;
14
+ /** The namespace that reads these Resources. */
15
+ readonly namespace: string;
16
+ /** One entry per foreign Resource, by name. */
17
+ readonly schema: {
18
+ readonly [resource: string]: AccessForeignEntry;
19
+ };
20
+ };
21
+ /**
22
+ * Checks that `file` is a foreign facts file: its format, its reading namespace, and
23
+ * each entry a schema entry marked foreign to another namespace, as the engine would
24
+ * check it in a policies file.
25
+ */
26
+ export declare const checkForeignFacts: (file: unknown) => AccessForeignFacts;
27
+ /** The foreign facts' canonical text: its members, and each entry's, sorted as a policies file's are. */
28
+ export declare const canonicalForeignFacts: (file: AccessForeignFacts) => string;
29
+ /** The foreign facts' hash: `sha256:<hex>`, the same for the same entries in any order. */
30
+ export declare const foreignFactsHash: (file: AccessForeignFacts) => string;
@@ -0,0 +1,75 @@
1
+ // The foreign facts file, format `aventara-access/foreign@1`: what `avaccess pull
2
+ // --foreign <Resource,…>` brings into a project, so the rules can read another
3
+ // namespace's Resources in conditions. It holds one schema entry per Resource,
4
+ // as the owner's current file states it and marked `{ "foreign": "<owner>" }`, with
5
+ // the relations to Resources the file does not hold left out. `avaccess schema`
6
+ // adds these entries to the model it writes; until they are pulled the Resources are
7
+ // absent from the rules' types.
8
+ //
9
+ // The file is pinned in `avAccess.lock.json` (`foreign`) and checked by `avaccess
10
+ // verify`, as every pulled file is. Its hash is the hash of the policies file that
11
+ // holds the same entries and no rule, so one function decides "the same facts".
12
+ import { AccessError } from "../access/access.error.js";
13
+ import { canonicalJson } from "../canonical-json.writer.js";
14
+ import { checkPolicies, isRecord } from "../policies.compiler.js";
15
+ import { canonicalPolicies, policiesHash } from "../policies.hash.js";
16
+ import { POLICIES_FORMAT, } from "../policies-contract.js";
17
+ /** The format marker of every foreign facts file. */
18
+ export const FOREIGN_FORMAT = "aventara-access/foreign@1";
19
+ /** The foreign facts file's name, beside the namespace's pulled policies file. */
20
+ export const FOREIGN_FILE = "foreign.json";
21
+ const MEMBERS = ["format", "namespace", "schema"];
22
+ /** The policies file with the same schema entries and no rule: what the facts are checked and hashed as. */
23
+ const asPolicies = (facts) => ({
24
+ format: POLICIES_FORMAT,
25
+ namespace: facts.namespace,
26
+ claims: {},
27
+ schema: facts.schema,
28
+ roles: {},
29
+ govern: {},
30
+ grants: [],
31
+ denies: [],
32
+ });
33
+ const invalid = (message) => {
34
+ throw new AccessError("ACCESS_POLICIES_INVALID", `foreign facts: ${message}`);
35
+ };
36
+ /**
37
+ * Checks that `file` is a foreign facts file: its format, its reading namespace, and
38
+ * each entry a schema entry marked foreign to another namespace, as the engine would
39
+ * check it in a policies file.
40
+ */
41
+ export const checkForeignFacts = (file) => {
42
+ if (!isRecord(file)) {
43
+ return invalid("a foreign facts file is a JSON object");
44
+ }
45
+ if (file.format !== FOREIGN_FORMAT) {
46
+ invalid(`format is ${JSON.stringify(file.format) ?? "missing"}, expected "${FOREIGN_FORMAT}"`);
47
+ }
48
+ const unknown = Object.keys(file).filter((key) => !MEMBERS.includes(key));
49
+ if (unknown.length > 0) {
50
+ invalid(`a foreign facts file has \`format\`, \`namespace\` and \`schema\`; it does not take ${unknown.map((key) => `\`${key}\``).join(", ")}`);
51
+ }
52
+ if (!isRecord(file.schema) || Object.keys(file.schema).length === 0) {
53
+ invalid("`schema` holds one entry per foreign Resource, and at least one");
54
+ }
55
+ for (const [resource, entry] of Object.entries(file.schema)) {
56
+ if (!isRecord(entry) || typeof entry.foreign !== "string") {
57
+ invalid(`${resource} is not marked { "foreign": "<owner>" }: every entry is another namespace's`);
58
+ }
59
+ }
60
+ try {
61
+ checkPolicies(asPolicies(file));
62
+ }
63
+ catch (error) {
64
+ invalid(String(error.message).replace(/^Aventara Access: /, ""));
65
+ }
66
+ return file;
67
+ };
68
+ /** The foreign facts' canonical text: its members, and each entry's, sorted as a policies file's are. */
69
+ export const canonicalForeignFacts = (file) => canonicalJson({
70
+ format: file.format,
71
+ namespace: file.namespace,
72
+ schema: JSON.parse(canonicalPolicies(asPolicies(file))).schema,
73
+ });
74
+ /** The foreign facts' hash: `sha256:<hex>`, the same for the same entries in any order. */
75
+ export const foreignFactsHash = (file) => policiesHash(asPolicies(checkForeignFacts(file)));
@@ -0,0 +1,10 @@
1
+ import type { AccessSchema } from "../access-schema.js";
2
+ import type { AccessForeignFacts } from "./foreign-facts.js";
3
+ /** The model with the foreign Resources marked, and what was left out and why. */
4
+ type ForeignMerge = {
5
+ readonly schema: AccessSchema;
6
+ readonly skipped: readonly string[];
7
+ };
8
+ /** `schema` (the ORM's reading) with the Resources `facts` holds marked foreign. */
9
+ export declare const mergeForeign: (schema: AccessSchema, facts: AccessForeignFacts, source: string) => ForeignMerge;
10
+ export {};
@@ -0,0 +1,52 @@
1
+ // The foreign facts, into the model `avaccess schema` writes. A Resource the facts
2
+ // hold is read from the service's own ORM schema (its field types are there, and the
3
+ // service's ORM needs the model to join through it) and marked `{ foreign: "<owner>" }`;
4
+ // it keeps only the fields the owner's file lists, and only the relations to
5
+ // Resources the model holds. One the ORM schema does not model is left out and
6
+ // reported: the service could not query through it.
7
+ //
8
+ // A model of the ORM schema the facts do not hold stays the service's own, and a
9
+ // push that names one another namespace owns is refused (`ACCESS_OWNERSHIP`).
10
+ /** `schema` (the ORM's reading) with the Resources `facts` holds marked foreign. */
11
+ export const mergeForeign = (schema, facts, source) => {
12
+ const skipped = [];
13
+ const resources = {};
14
+ for (const [name, resource] of Object.entries(schema.resources)) {
15
+ resources[name] = resource;
16
+ }
17
+ for (const [name, entry] of Object.entries(facts.schema)) {
18
+ const read = Object.hasOwn(resources, name) ? resources[name] : undefined;
19
+ if (read === undefined) {
20
+ skipped.push(`${name}: pulled as namespace "${entry.foreign}"'s, and ${source} has no model for it, so the service could not read through it; add the model (over "${entry.foreign}"'s table) to read it; left out`);
21
+ continue;
22
+ }
23
+ const fields = {};
24
+ for (const [field, definition] of Object.entries(read.fields)) {
25
+ if (entry.fields.includes(field)) {
26
+ fields[field] = definition;
27
+ }
28
+ else {
29
+ skipped.push(`${name}.${field}: namespace "${entry.foreign}"'s file does not list it; left out`);
30
+ }
31
+ }
32
+ resources[name] = {
33
+ fields,
34
+ identifiers: read.identifiers.filter((identifier) => identifier.every((field) => Object.hasOwn(fields, field))),
35
+ foreign: entry.foreign,
36
+ };
37
+ }
38
+ // A relation to a Resource the model no longer holds has nothing to lead to.
39
+ for (const [name, resource] of Object.entries(resources)) {
40
+ const fields = {};
41
+ for (const [field, definition] of Object.entries(resource.fields)) {
42
+ if (definition.kind === "relation" &&
43
+ !Object.hasOwn(resources, definition.target)) {
44
+ skipped.push(`${name}.${field}: a relation to ${definition.target}, which the model does not hold; left out`);
45
+ continue;
46
+ }
47
+ fields[field] = definition;
48
+ }
49
+ resources[name] = { ...resource, fields };
50
+ }
51
+ return { schema: { enums: schema.enums, resources }, skipped };
52
+ };
@@ -0,0 +1,12 @@
1
+ import type { AccessPolicies } from "./policies-contract.js";
2
+ /**
3
+ * The warnings for `policies` hosted by a framework that serves the Resources in
4
+ * `held`, one per writing role and foreign target, each naming every reference
5
+ * that role may write to it unconstrained.
6
+ */
7
+ export declare const foreignReferenceWarnings: (policies: AccessPolicies, held: ReadonlySet<string>) => readonly string[];
8
+ /**
9
+ * Emits `policies`' warnings under a framework that serves the Resources in
10
+ * `held`, each once per process and namespace.
11
+ */
12
+ export declare const warnForeignReferences: (policies: AccessPolicies, held: ReadonlySet<string>) => void;
@@ -0,0 +1,134 @@
1
+ // A check of the rules against the framework that hosts them: a role that may
2
+ // create or update a row holding a reference (a to-one relation's reference fields)
3
+ // to a foreign Resource, with no condition of the write grant through that
4
+ // relation. A foreign Resource is read in conditions and never granted on, so the
5
+ // engine lists it in `unheld` and Aventara holds no written reference to it to the
6
+ // caller's read scope (which is `false` for every caller there): the write grant
7
+ // alone decides which row may be linked. A grant that reads nothing through the
8
+ // relation lets the caller link any row of the foreign Resource by its identifier.
9
+ //
10
+ // A grant constrains the link when one of the conditions checked on the row the
11
+ // write leaves reads the relation: on a create, its `where`, its `in`, and the row
12
+ // condition of a subject role held on the writing Resource; on an update, those and
13
+ // its `after` (an update grant's `where` is checked on the row it leaves too). A
14
+ // condition reads the relation when it filters through it (`{ campaign: { … } }`)
15
+ // or compares its reference field with `equals` or `in` (`{ campaignId: me.id }`),
16
+ // itself or in every branch of an `OR` or any part of an `AND`; one under a `NOT`,
17
+ // or a `not` / `notIn` comparison, excludes rows and leaves every other one
18
+ // linkable. "Constrains" says nothing of whether the condition is the right one:
19
+ // it only finds the grants that check nothing.
20
+ //
21
+ // It runs when the framework hosts the engine (`hosted({ held })`), as
22
+ // ACCESS_REFERENCE_UNREADABLE does, for the writing Resources the framework serves
23
+ // (`held`). It warns and never refuses: the engine answers exactly as before. Not
24
+ // warned about: a reference the grant's `write.except` locks.
25
+ import { emitAccessWarning } from "./access-warning.emitter.js";
26
+ import { ownOf } from "./scope.resolver.js";
27
+ const WARNING_CODE = "ACCESS_FOREIGN_REFERENCE_UNCHECKED";
28
+ /** Whether `predicate`, holding on a row, limits which row its `relation` links. */
29
+ const constrains = (predicate, relation, fields) => {
30
+ if (predicate === undefined) {
31
+ return false;
32
+ }
33
+ switch (predicate.op) {
34
+ case "false":
35
+ return true;
36
+ case "and":
37
+ return predicate.args.some((arg) => constrains(arg, relation, fields));
38
+ case "or":
39
+ return (predicate.args.length > 0 &&
40
+ predicate.args.every((arg) => constrains(arg, relation, fields)));
41
+ case "cmp":
42
+ return (fields.includes(predicate.field) &&
43
+ (predicate.cmp === "eq" || predicate.cmp === "in"));
44
+ case "rel":
45
+ return predicate.relation === relation && predicate.where.op !== "true";
46
+ default:
47
+ return false;
48
+ }
49
+ };
50
+ /** The row condition of `subject` when it is a role held on rows of `resource`. */
51
+ const subjectRowCondition = (policies, subject, resource) => {
52
+ const definition = ownOf(policies.roles, subject);
53
+ return definition !== undefined &&
54
+ "on" in definition &&
55
+ definition.on === resource
56
+ ? definition.where
57
+ : undefined;
58
+ };
59
+ /** Whether `grant`, for `subject`, constrains the link `relation` makes on `action`. */
60
+ const grantConstrains = (policies, grant, subject, resource, action, relation, fields) => grant.in === relation ||
61
+ constrains(grant.where, relation, fields) ||
62
+ (action === "update" && constrains(grant.after, relation, fields)) ||
63
+ constrains(subjectRowCondition(policies, subject, resource), relation, fields);
64
+ const listed = (names) => names.length < 2
65
+ ? names.join("")
66
+ : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
67
+ /**
68
+ * The warnings for `policies` hosted by a framework that serves the Resources in
69
+ * `held`, one per writing role and foreign target, each naming every reference
70
+ * that role may write to it unconstrained.
71
+ */
72
+ export const foreignReferenceWarnings = (policies, held) => {
73
+ const found = new Map();
74
+ for (const grant of policies.grants) {
75
+ const actions = grant.actions.filter((action) => action === "create" || action === "update");
76
+ if (actions.length === 0) {
77
+ continue;
78
+ }
79
+ const locked = new Set(grant.write?.except ?? []);
80
+ const resources = grant.resource === "*" ? Object.keys(policies.schema) : [grant.resource];
81
+ for (const resource of resources) {
82
+ const facts = ownOf(policies.schema, resource);
83
+ if (facts === undefined ||
84
+ facts.foreign !== undefined ||
85
+ !held.has(resource)) {
86
+ continue;
87
+ }
88
+ for (const [relation, fact] of Object.entries(facts.relations)) {
89
+ const fields = fact.referenceFields;
90
+ const owner = ownOf(policies.schema, fact.target)?.foreign;
91
+ if (owner === undefined ||
92
+ fact.cardinality !== "one" ||
93
+ fields === undefined ||
94
+ locked.has(relation) ||
95
+ fields.some((field) => locked.has(field))) {
96
+ continue;
97
+ }
98
+ for (const writer of grant.subjects) {
99
+ if (actions.every((action) => grantConstrains(policies, grant, writer, resource, action, relation, fields))) {
100
+ continue;
101
+ }
102
+ const key = `${writer}\u0000${fact.target}`;
103
+ const entry = found.get(key) ?? {
104
+ writer,
105
+ target: fact.target,
106
+ owner,
107
+ relation,
108
+ references: [],
109
+ };
110
+ for (const field of fields) {
111
+ const name = `${resource}.${field}`;
112
+ if (!entry.references.includes(name)) {
113
+ entry.references.push(name);
114
+ }
115
+ }
116
+ found.set(key, entry);
117
+ }
118
+ }
119
+ }
120
+ }
121
+ return [...found.values()].map(({ writer, target, owner, relation, references }) => {
122
+ const one = references.length === 1;
123
+ return `${writer} may write ${listed(references)}, ${one ? "a reference" : "references"} to Resource "${target}", which namespace "${owner}" owns (foreign), and no condition of the write grant reads that relation, so any row of "${target}" can be linked by its identifier. Aventara does not hold a reference to a foreign Resource to what the caller may read: the write grant decides which row may be linked. Constrain it through the relation (a \`where\` on a create grant, an \`after\` on an update grant, e.g. \`{ ${relation}: { … } }\`), or leave the reference out of the write (\`write: { except: [...] }\`).`;
124
+ });
125
+ };
126
+ /**
127
+ * Emits `policies`' warnings under a framework that serves the Resources in
128
+ * `held`, each once per process and namespace.
129
+ */
130
+ export const warnForeignReferences = (policies, held) => {
131
+ for (const message of foreignReferenceWarnings(policies, held)) {
132
+ emitAccessWarning(WARNING_CODE, policies.namespace, message);
133
+ }
134
+ };