web-agent-bridge 3.0.0 → 3.3.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 (202) hide show
  1. package/LICENSE +72 -21
  2. package/README.ar.md +1286 -1073
  3. package/README.md +1764 -1535
  4. package/bin/agent-runner.js +474 -474
  5. package/bin/cli.js +237 -138
  6. package/bin/wab.js +80 -80
  7. package/examples/bidi-agent.js +119 -119
  8. package/examples/cross-site-agent.js +91 -91
  9. package/examples/mcp-agent.js +94 -94
  10. package/examples/next-app-router/README.md +44 -44
  11. package/examples/puppeteer-agent.js +108 -108
  12. package/examples/saas-dashboard/README.md +55 -55
  13. package/examples/shopify-hydrogen/README.md +74 -74
  14. package/examples/vision-agent.js +171 -171
  15. package/examples/wordpress-elementor/README.md +77 -77
  16. package/package.json +17 -3
  17. package/public/.well-known/agent-tools.json +180 -180
  18. package/public/.well-known/ai-assets.json +59 -59
  19. package/public/.well-known/ai-plugin.json +28 -0
  20. package/public/.well-known/security.txt +8 -0
  21. package/public/agent-workspace.html +349 -347
  22. package/public/ai.html +198 -196
  23. package/public/api.html +413 -0
  24. package/public/browser.html +486 -484
  25. package/public/commander-dashboard.html +243 -243
  26. package/public/cookies.html +210 -208
  27. package/public/css/agent-workspace.css +1713 -1713
  28. package/public/css/premium.css +317 -317
  29. package/public/css/styles.css +1235 -1235
  30. package/public/dashboard.html +706 -704
  31. package/public/demo.html +1770 -1
  32. package/public/dns.html +507 -0
  33. package/public/docs.html +587 -585
  34. package/public/feed.xml +89 -89
  35. package/public/growth.html +463 -0
  36. package/public/index.html +341 -9
  37. package/public/integrations.html +556 -0
  38. package/public/js/agent-workspace.js +1740 -1740
  39. package/public/js/auth-nav.js +31 -31
  40. package/public/js/auth-redirect.js +12 -12
  41. package/public/js/cookie-consent.js +56 -56
  42. package/public/js/wab-demo-page.js +721 -721
  43. package/public/js/ws-client.js +74 -74
  44. package/public/llms-full.txt +360 -309
  45. package/public/llms.txt +125 -86
  46. package/public/login.html +85 -83
  47. package/public/mesh-dashboard.html +328 -328
  48. package/public/openapi.json +580 -580
  49. package/public/phone-shield.html +281 -0
  50. package/public/premium-dashboard.html +2489 -2487
  51. package/public/premium.html +793 -791
  52. package/public/privacy.html +297 -295
  53. package/public/register.html +105 -103
  54. package/public/robots.txt +87 -87
  55. package/public/script/wab-consent.d.ts +36 -36
  56. package/public/script/wab-consent.js +104 -104
  57. package/public/script/wab-schema.js +131 -131
  58. package/public/script/wab.d.ts +108 -108
  59. package/public/script/wab.min.js +580 -580
  60. package/public/security.txt +8 -0
  61. package/public/terms.html +256 -254
  62. package/script/ai-agent-bridge.js +1754 -1754
  63. package/sdk/README.md +99 -99
  64. package/sdk/agent-mesh.js +449 -449
  65. package/sdk/commander.js +262 -262
  66. package/sdk/index.d.ts +464 -464
  67. package/sdk/index.js +18 -1
  68. package/sdk/multi-agent.js +318 -318
  69. package/sdk/package.json +12 -1
  70. package/sdk/safety-shield.js +219 -0
  71. package/sdk/schema-discovery.js +83 -83
  72. package/server/adapters/index.js +520 -520
  73. package/server/config/plans.js +367 -367
  74. package/server/config/secrets.js +102 -102
  75. package/server/control-plane/index.js +301 -301
  76. package/server/data-plane/index.js +354 -354
  77. package/server/index.js +175 -19
  78. package/server/llm/index.js +404 -404
  79. package/server/middleware/adminAuth.js +35 -35
  80. package/server/middleware/auth.js +50 -50
  81. package/server/middleware/featureGate.js +88 -88
  82. package/server/middleware/rateLimits.js +100 -100
  83. package/server/middleware/sensitiveAction.js +157 -0
  84. package/server/migrations/001_add_analytics_indexes.sql +7 -7
  85. package/server/migrations/002_premium_features.sql +418 -418
  86. package/server/migrations/003_ads_integer_cents.sql +33 -33
  87. package/server/migrations/004_agent_os.sql +158 -158
  88. package/server/migrations/005_marketplace_metering.sql +126 -126
  89. package/server/models/adapters/index.js +33 -33
  90. package/server/models/adapters/mysql.js +183 -183
  91. package/server/models/adapters/postgresql.js +172 -172
  92. package/server/models/adapters/sqlite.js +7 -7
  93. package/server/models/db.js +681 -681
  94. package/server/observability/failure-analysis.js +337 -337
  95. package/server/observability/index.js +394 -394
  96. package/server/protocol/capabilities.js +223 -223
  97. package/server/protocol/index.js +243 -243
  98. package/server/protocol/schema.js +584 -584
  99. package/server/registry/certification.js +271 -271
  100. package/server/registry/index.js +326 -326
  101. package/server/routes/admin-premium.js +671 -671
  102. package/server/routes/admin.js +261 -261
  103. package/server/routes/ads.js +130 -130
  104. package/server/routes/agent-workspace.js +540 -378
  105. package/server/routes/api.js +150 -150
  106. package/server/routes/auth.js +71 -71
  107. package/server/routes/billing.js +45 -45
  108. package/server/routes/commander.js +316 -316
  109. package/server/routes/demo-showcase.js +332 -0
  110. package/server/routes/demo-store.js +154 -0
  111. package/server/routes/discovery.js +417 -406
  112. package/server/routes/gateway.js +173 -0
  113. package/server/routes/license.js +251 -240
  114. package/server/routes/mesh.js +469 -469
  115. package/server/routes/noscript.js +543 -543
  116. package/server/routes/premium-v2.js +686 -686
  117. package/server/routes/premium.js +724 -724
  118. package/server/routes/runtime.js +2148 -2147
  119. package/server/routes/sovereign.js +465 -385
  120. package/server/routes/universal.js +200 -177
  121. package/server/routes/wab-api.js +850 -491
  122. package/server/runtime/container-worker.js +111 -111
  123. package/server/runtime/container.js +448 -448
  124. package/server/runtime/distributed-worker.js +362 -362
  125. package/server/runtime/event-bus.js +210 -210
  126. package/server/runtime/index.js +253 -253
  127. package/server/runtime/queue.js +599 -599
  128. package/server/runtime/replay.js +666 -666
  129. package/server/runtime/sandbox.js +266 -266
  130. package/server/runtime/scheduler.js +534 -534
  131. package/server/runtime/session-engine.js +293 -293
  132. package/server/runtime/state-manager.js +188 -188
  133. package/server/security/cross-site-redactor.js +196 -0
  134. package/server/security/dry-run.js +180 -0
  135. package/server/security/human-gate-rate-limit.js +147 -0
  136. package/server/security/human-gate-transports.js +178 -0
  137. package/server/security/human-gate.js +281 -0
  138. package/server/security/index.js +368 -368
  139. package/server/security/intent-engine.js +245 -0
  140. package/server/security/reward-guard.js +171 -0
  141. package/server/security/rollback-store.js +239 -0
  142. package/server/security/token-scope.js +404 -0
  143. package/server/security/url-policy.js +139 -0
  144. package/server/services/agent-chat.js +506 -506
  145. package/server/services/agent-learning.js +601 -575
  146. package/server/services/agent-memory.js +625 -625
  147. package/server/services/agent-mesh.js +555 -539
  148. package/server/services/agent-symphony.js +717 -717
  149. package/server/services/agent-tasks.js +1807 -1807
  150. package/server/services/api-key-engine.js +292 -0
  151. package/server/services/cluster.js +894 -894
  152. package/server/services/commander.js +738 -738
  153. package/server/services/edge-compute.js +440 -440
  154. package/server/services/email.js +204 -204
  155. package/server/services/hosted-runtime.js +205 -205
  156. package/server/services/lfd.js +635 -616
  157. package/server/services/local-ai.js +389 -389
  158. package/server/services/marketplace.js +270 -270
  159. package/server/services/metering.js +182 -182
  160. package/server/services/modules/affiliate-intelligence.js +93 -0
  161. package/server/services/modules/agent-firewall.js +90 -0
  162. package/server/services/modules/bounty.js +89 -0
  163. package/server/services/modules/collective-bargaining.js +92 -0
  164. package/server/services/modules/dark-pattern.js +66 -0
  165. package/server/services/modules/gov-intelligence.js +45 -0
  166. package/server/services/modules/neural.js +55 -0
  167. package/server/services/modules/notary.js +49 -0
  168. package/server/services/modules/price-time-machine.js +86 -0
  169. package/server/services/modules/protocol.js +104 -0
  170. package/server/services/negotiation.js +439 -439
  171. package/server/services/plugins.js +771 -771
  172. package/server/services/premium.js +1 -1
  173. package/server/services/price-intelligence.js +566 -565
  174. package/server/services/price-shield.js +1137 -1137
  175. package/server/services/reputation.js +465 -465
  176. package/server/services/search-engine.js +357 -357
  177. package/server/services/security.js +513 -513
  178. package/server/services/self-healing.js +843 -843
  179. package/server/services/sovereign-shield.js +542 -0
  180. package/server/services/stripe.js +192 -192
  181. package/server/services/swarm.js +788 -788
  182. package/server/services/universal-scraper.js +662 -661
  183. package/server/services/verification.js +481 -481
  184. package/server/services/vision.js +1163 -1163
  185. package/server/utils/cache.js +125 -125
  186. package/server/utils/migrate.js +81 -81
  187. package/server/utils/safe-fetch.js +228 -0
  188. package/server/utils/secureFields.js +50 -50
  189. package/server/ws.js +161 -161
  190. package/templates/artisan-marketplace.yaml +104 -104
  191. package/templates/book-price-scout.yaml +98 -98
  192. package/templates/electronics-price-tracker.yaml +108 -108
  193. package/templates/flight-deal-hunter.yaml +113 -113
  194. package/templates/freelancer-direct.yaml +116 -116
  195. package/templates/grocery-price-compare.yaml +93 -93
  196. package/templates/hotel-direct-booking.yaml +113 -113
  197. package/templates/local-services.yaml +98 -98
  198. package/templates/olive-oil-tunisia.yaml +88 -88
  199. package/templates/organic-farm-fresh.yaml +101 -101
  200. package/templates/restaurant-direct.yaml +97 -97
  201. package/server/services/fairness-engine.js +0 -409
  202. package/server/services/fairness.js +0 -420
@@ -0,0 +1,404 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * WAB Safety Shield — Scoped Session Tokens
5
+ *
6
+ * Implements the runtime side of WAB SPEC §8.7 (Scoped Session Tokens).
7
+ *
8
+ * Threat model: a leaked or compromised session token issued for one
9
+ * environment / one access level (e.g. `read` in `staging`) MUST NOT be
10
+ * usable to perform a destructive operation in production. This is the
11
+ * safety primitive that prevents PocketOS-class incidents where a single
12
+ * unscoped token straddles staging and production with full write access.
13
+ *
14
+ * Design:
15
+ * - Scope is a triplet (access, env, resources)
16
+ * access : 'read' | 'write' | 'admin' (hierarchy: admin > write > read)
17
+ * env : 'sandbox' | 'staging' | 'production' | '*' ('*' = any)
18
+ * resources : array of glob patterns OR ['*'] (default ['*'])
19
+ * - A separate boolean axis: destructive=true is required for any command
20
+ * that matches the SPEC default destructive verb list OR the site's
21
+ * wab.json `destructiveActions` array. `read` scope NEVER allows
22
+ * destructive, regardless of environment. `admin` always does.
23
+ * - Scopes can only be NARROWED, never widened. When a token issuer is
24
+ * itself scoped (delegation), the issued scope is the intersection of
25
+ * parent and requested.
26
+ *
27
+ * Error codes (returned to clients):
28
+ * INVALID_SCOPE — scope string/object did not parse
29
+ * INSUFFICIENT_SCOPE — token doesn't satisfy required access level
30
+ * ENV_MISMATCH — token env doesn't include requested env
31
+ * READONLY_VIOLATION — read-scope token tried to perform a write
32
+ * DESTRUCTIVE_REQUIRES_WRITE — token cannot perform destructive ops
33
+ * RESOURCE_OUT_OF_SCOPE — resource glob doesn't include the target
34
+ *
35
+ * This module is intentionally pure (no DB, no Express deps) so it can be
36
+ * unit-tested in isolation.
37
+ */
38
+
39
+ // ─── Constants ───────────────────────────────────────────────────────
40
+
41
+ const ACCESS_LEVELS = ['read', 'write', 'admin'];
42
+ const ENVIRONMENTS = ['sandbox', 'staging', 'production'];
43
+ const ANY_ENV = '*';
44
+ const ANY_RESOURCE = '*';
45
+
46
+ // SPEC §8.7.3 — default destructive verb list (lower-case). Sites may
47
+ // extend this via wab.json `destructiveActions: [...]` and may suppress
48
+ // individual verbs via `nonDestructiveActions: [...]`.
49
+ const DEFAULT_DESTRUCTIVE_VERBS = Object.freeze([
50
+ 'delete', 'destroy', 'drop', 'truncate', 'purge', 'wipe', 'erase',
51
+ 'remove', 'unlink', 'rm', 'rmdir',
52
+ 'reset', 'reinit', 'reformat', 'format',
53
+ 'shutdown', 'terminate', 'kill',
54
+ 'revoke', 'disable', 'deactivate',
55
+ 'volume-delete', 'volumedelete', 'db-drop', 'database-drop',
56
+ ]);
57
+
58
+ const DESTRUCTIVE_VERBS_SET = new Set(DEFAULT_DESTRUCTIVE_VERBS);
59
+
60
+ // ─── Aliases (legacy / human-friendly inputs) ────────────────────────
61
+
62
+ const ACCESS_ALIASES = {
63
+ readonly: 'read',
64
+ ro: 'read',
65
+ read: 'read',
66
+ rw: 'write',
67
+ write: 'write',
68
+ full: 'admin',
69
+ admin: 'admin',
70
+ };
71
+
72
+ const ENV_ALIASES = {
73
+ prod: 'production',
74
+ production: 'production',
75
+ live: 'production',
76
+ staging: 'staging',
77
+ stage: 'staging',
78
+ test: 'sandbox',
79
+ sandbox: 'sandbox',
80
+ dev: 'sandbox',
81
+ development: 'sandbox',
82
+ };
83
+
84
+ // ─── Errors ──────────────────────────────────────────────────────────
85
+
86
+ class ScopeError extends Error {
87
+ constructor(code, message) {
88
+ super(message);
89
+ this.name = 'ScopeError';
90
+ this.code = code;
91
+ }
92
+ }
93
+
94
+ // ─── Parsing & canonicalisation ──────────────────────────────────────
95
+
96
+ /**
97
+ * Parse arbitrary scope input into a canonical object.
98
+ *
99
+ * Accepts:
100
+ * - undefined / null → admin/* (legacy unscoped tokens)
101
+ * - string "readonly" → { access:'read', env:['*'], resources:['*'] }
102
+ * - string "read:staging" → { access:'read', env:['staging'], resources:['*'] }
103
+ * - string "write:staging,prod"→ { access:'write', env:['staging','production'], resources:['*'] }
104
+ * - string "read:*:cart.*" → { access:'read', env:['*'], resources:['cart.*'] }
105
+ * - object { access, env, resources }
106
+ *
107
+ * Returns canonical: { access, envs:Set<string>|null, resources:string[],
108
+ * legacyUnscoped:bool }
109
+ *
110
+ * envs === null ⇨ any environment ('*')
111
+ *
112
+ * Throws ScopeError('INVALID_SCOPE') on malformed input.
113
+ */
114
+ function parseScope(input) {
115
+ // Legacy: no scope provided. Pre-§8.7 behaviour preserved for backward compat.
116
+ if (input == null || input === '' || input === '*') {
117
+ return {
118
+ access: 'admin',
119
+ envs: null,
120
+ resources: ['*'],
121
+ legacyUnscoped: true,
122
+ };
123
+ }
124
+
125
+ let access;
126
+ let envParts;
127
+ let resources;
128
+
129
+ if (typeof input === 'string') {
130
+ const segments = input.split(':').map((s) => s.trim()).filter(Boolean);
131
+ if (segments.length === 0) throw new ScopeError('INVALID_SCOPE', 'empty scope string');
132
+ access = _normalizeAccess(segments[0]);
133
+ envParts = segments[1] ? segments[1].split(',').map((s) => s.trim()) : ['*'];
134
+ resources = segments[2] ? segments[2].split(',').map((s) => s.trim()) : ['*'];
135
+ } else if (typeof input === 'object') {
136
+ access = _normalizeAccess(input.access || input.level || 'read');
137
+ const rawEnv = input.env != null ? input.env : (input.environment != null ? input.environment : '*');
138
+ envParts = Array.isArray(rawEnv) ? rawEnv : String(rawEnv).split(',');
139
+ envParts = envParts.map((s) => String(s).trim()).filter(Boolean);
140
+ if (envParts.length === 0) envParts = ['*'];
141
+ const rawRes = input.resources != null ? input.resources : '*';
142
+ resources = Array.isArray(rawRes) ? rawRes : [String(rawRes)];
143
+ resources = resources.map((s) => String(s).trim()).filter(Boolean);
144
+ if (resources.length === 0) resources = ['*'];
145
+ } else {
146
+ throw new ScopeError('INVALID_SCOPE', `unsupported scope input type: ${typeof input}`);
147
+ }
148
+
149
+ // Resolve envs.
150
+ let envs;
151
+ if (envParts.includes('*') || envParts.includes(ANY_ENV)) {
152
+ envs = null;
153
+ } else {
154
+ envs = new Set();
155
+ for (const e of envParts) {
156
+ const norm = ENV_ALIASES[e.toLowerCase()];
157
+ if (!norm) throw new ScopeError('INVALID_SCOPE', `unknown environment "${e}"`);
158
+ envs.add(norm);
159
+ }
160
+ if (envs.size === 0) envs = null;
161
+ }
162
+
163
+ // Validate resource patterns: limit to ASCII-safe glob, no spaces, ≤256 chars.
164
+ for (const r of resources) {
165
+ if (r.length > 256 || /[\s\x00-\x1f]/.test(r)) {
166
+ throw new ScopeError('INVALID_SCOPE', `invalid resource pattern "${r}"`);
167
+ }
168
+ }
169
+
170
+ return { access, envs, resources, legacyUnscoped: false };
171
+ }
172
+
173
+ function _normalizeAccess(raw) {
174
+ const a = ACCESS_ALIASES[String(raw).toLowerCase()];
175
+ if (!a) throw new ScopeError('INVALID_SCOPE', `unknown access level "${raw}"`);
176
+ return a;
177
+ }
178
+
179
+ /** Stable string form for logging / token serialisation. */
180
+ function formatScope(scope) {
181
+ if (!scope) return '*';
182
+ if (scope.legacyUnscoped) return '*';
183
+ const env = scope.envs == null ? '*' : Array.from(scope.envs).sort().join(',');
184
+ const res = scope.resources.join(',');
185
+ return `${scope.access}:${env}:${res}`;
186
+ }
187
+
188
+ // ─── Hierarchy & intersection ────────────────────────────────────────
189
+
190
+ function _accessRank(a) {
191
+ return ACCESS_LEVELS.indexOf(a);
192
+ }
193
+
194
+ /**
195
+ * Return the most restrictive scope that satisfies BOTH parent and child.
196
+ * Used when an issuer (already scoped) delegates a narrower scope to a
197
+ * sub-token — the result must never exceed the parent's authority.
198
+ *
199
+ * Throws ScopeError('INSUFFICIENT_SCOPE') if child requests more than parent.
200
+ */
201
+ function intersectScopes(parent, child) {
202
+ const p = _ensureScope(parent);
203
+ const c = _ensureScope(child);
204
+
205
+ // Access: must be ≤ parent.
206
+ if (_accessRank(c.access) > _accessRank(p.access)) {
207
+ throw new ScopeError('INSUFFICIENT_SCOPE',
208
+ `requested access "${c.access}" exceeds parent "${p.access}"`);
209
+ }
210
+
211
+ // Envs.
212
+ let envs;
213
+ if (p.envs == null) {
214
+ envs = c.envs == null ? null : new Set(c.envs);
215
+ } else if (c.envs == null) {
216
+ envs = new Set(p.envs);
217
+ } else {
218
+ envs = new Set([...c.envs].filter((e) => p.envs.has(e)));
219
+ if (envs.size === 0) {
220
+ throw new ScopeError('ENV_MISMATCH', 'requested environments not allowed by parent');
221
+ }
222
+ }
223
+
224
+ // Resources: child must be a subset (or '*' which inherits parent).
225
+ let resources;
226
+ if (c.resources.length === 1 && c.resources[0] === '*') {
227
+ resources = [...p.resources];
228
+ } else if (p.resources.length === 1 && p.resources[0] === '*') {
229
+ resources = [...c.resources];
230
+ } else {
231
+ // Each child pattern must be covered by at least one parent pattern.
232
+ for (const cp of c.resources) {
233
+ const ok = p.resources.some((pp) => _resourceCovers(pp, cp));
234
+ if (!ok) {
235
+ throw new ScopeError('INSUFFICIENT_SCOPE',
236
+ `requested resource "${cp}" exceeds parent`);
237
+ }
238
+ }
239
+ resources = [...c.resources];
240
+ }
241
+
242
+ return { access: c.access, envs, resources, legacyUnscoped: false };
243
+ }
244
+
245
+ function _resourceCovers(parentPattern, childPattern) {
246
+ // Conservative: only consider trailing-* globs and exact equality.
247
+ if (parentPattern === '*' || parentPattern === childPattern) return true;
248
+ if (parentPattern.endsWith('.*') || parentPattern.endsWith('/*')) {
249
+ const prefix = parentPattern.slice(0, -1);
250
+ return childPattern === prefix.slice(0, -1) || childPattern.startsWith(prefix);
251
+ }
252
+ return false;
253
+ }
254
+
255
+ function _ensureScope(s) {
256
+ return s && typeof s === 'object' && 'access' in s ? s : parseScope(s);
257
+ }
258
+
259
+ // ─── Destructive verb classification ─────────────────────────────────
260
+
261
+ /**
262
+ * Decide whether `actionName` (within `siteConfig`) is destructive.
263
+ *
264
+ * Order:
265
+ * 1. siteConfig.nonDestructiveActions[] → forces non-destructive (override)
266
+ * 2. siteConfig.destructiveActions[] → forces destructive
267
+ * 3. SPEC default destructive verb list → fallback
268
+ */
269
+ function isDestructiveAction(actionName, siteConfig = {}) {
270
+ if (!actionName) return false;
271
+ const raw = String(actionName);
272
+ // Insert a separator at camelCase boundaries BEFORE lowercasing so we can
273
+ // catch forms like "deleteVolume", "dropTable", "purgeBackups".
274
+ const camelExpanded = raw.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
275
+ const lower = camelExpanded;
276
+
277
+ const nonD = Array.isArray(siteConfig.nonDestructiveActions)
278
+ ? siteConfig.nonDestructiveActions.map((s) => String(s).toLowerCase())
279
+ : [];
280
+ if (nonD.includes(raw.toLowerCase()) || nonD.includes(lower)) return false;
281
+
282
+ const extra = Array.isArray(siteConfig.destructiveActions)
283
+ ? siteConfig.destructiveActions.map((s) => String(s).toLowerCase())
284
+ : [];
285
+ if (extra.includes(raw.toLowerCase()) || extra.includes(lower)) return true;
286
+
287
+ // Token-split match against the camelCase-expanded form.
288
+ const tokens = lower.split(/[\s.\-_/:]+/).filter(Boolean);
289
+ for (const t of tokens) {
290
+ if (DESTRUCTIVE_VERBS_SET.has(t)) return true;
291
+ if (extra.includes(t)) return true;
292
+ }
293
+ return false;
294
+ }
295
+
296
+ // ─── Authorisation decision ──────────────────────────────────────────
297
+
298
+ /**
299
+ * The single authoritative authorisation check.
300
+ *
301
+ * @param {object} scope Canonical scope (from parseScope).
302
+ * @param {object} command { name, env, resource, action_kind?, destructive? }
303
+ * - name : action identifier (e.g. 'delete')
304
+ * - env : 'production' | 'staging' | 'sandbox'
305
+ * - resource : optional resource id (e.g. 'orders.cart')
306
+ * - action_kind : 'read' | 'write' | 'admin'
307
+ * (defaults: 'admin' if destructive,
308
+ * 'write' if name not GET-like, else 'read')
309
+ * - destructive : boolean override (otherwise inferred
310
+ * via isDestructiveAction + siteConfig)
311
+ * @param {object} siteConfig parsed wab.json
312
+ *
313
+ * @returns {{allowed: true} | {allowed: false, code: string, reason: string}}
314
+ */
315
+ function authorize(scope, command, siteConfig = {}) {
316
+ const sc = _ensureScope(scope);
317
+ const cmd = command || {};
318
+ const env = cmd.env ? ENV_ALIASES[String(cmd.env).toLowerCase()] || cmd.env : null;
319
+
320
+ // 1. Environment match.
321
+ if (sc.envs != null && env && !sc.envs.has(env)) {
322
+ return {
323
+ allowed: false,
324
+ code: 'ENV_MISMATCH',
325
+ reason: `token does not include environment "${env}" (allowed: ${[...sc.envs].join(',')})`,
326
+ };
327
+ }
328
+
329
+ // 2. Destructive flag.
330
+ const destructive = cmd.destructive === true ||
331
+ isDestructiveAction(cmd.name, siteConfig);
332
+
333
+ if (destructive) {
334
+ if (sc.access === 'read') {
335
+ return {
336
+ allowed: false,
337
+ code: 'DESTRUCTIVE_REQUIRES_WRITE',
338
+ reason: `destructive action "${cmd.name}" cannot be performed by a read-scope token`,
339
+ };
340
+ }
341
+ // write and admin both pass the destructive gate; site policy may still
342
+ // require admin via an explicit action_kind hint below.
343
+ }
344
+
345
+ // 3. Access level.
346
+ const requiredAccess = _requiredAccessForCommand(cmd, destructive);
347
+ if (_accessRank(sc.access) < _accessRank(requiredAccess)) {
348
+ const code = (sc.access === 'read' && requiredAccess === 'write')
349
+ ? 'READONLY_VIOLATION'
350
+ : 'INSUFFICIENT_SCOPE';
351
+ return {
352
+ allowed: false,
353
+ code,
354
+ reason: `command requires "${requiredAccess}" but token has "${sc.access}"`,
355
+ };
356
+ }
357
+
358
+ // 4. Resource glob.
359
+ if (cmd.resource && !_resourceMatchesAny(cmd.resource, sc.resources)) {
360
+ return {
361
+ allowed: false,
362
+ code: 'RESOURCE_OUT_OF_SCOPE',
363
+ reason: `resource "${cmd.resource}" not in token scope`,
364
+ };
365
+ }
366
+
367
+ return { allowed: true };
368
+ }
369
+
370
+ function _requiredAccessForCommand(cmd, destructive) {
371
+ if (cmd.action_kind && ACCESS_LEVELS.includes(cmd.action_kind)) return cmd.action_kind;
372
+ if (destructive) return 'write';
373
+ const READ_ONLY_PATTERNS = /^(read|get|list|search|find|view|page-info|ping|discover|actions)/i;
374
+ if (cmd.name && READ_ONLY_PATTERNS.test(String(cmd.name))) return 'read';
375
+ return 'write';
376
+ }
377
+
378
+ function _resourceMatchesAny(target, patterns) {
379
+ for (const p of patterns) {
380
+ if (p === '*' || p === target) return true;
381
+ if (p.endsWith('.*') || p.endsWith('/*')) {
382
+ const prefix = p.slice(0, -1);
383
+ if (target === prefix.slice(0, -1) || target.startsWith(prefix)) return true;
384
+ }
385
+ }
386
+ return false;
387
+ }
388
+
389
+ // ─── Public API ──────────────────────────────────────────────────────
390
+
391
+ module.exports = {
392
+ // parsing
393
+ parseScope,
394
+ formatScope,
395
+ intersectScopes,
396
+ // policy
397
+ authorize,
398
+ isDestructiveAction,
399
+ // diagnostics / introspection
400
+ ScopeError,
401
+ ACCESS_LEVELS,
402
+ ENVIRONMENTS,
403
+ DEFAULT_DESTRUCTIVE_VERBS,
404
+ };
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * URL Policy — guards public endpoints (e.g. /api/universal/extract) that
5
+ * accept arbitrary user URLs. Layered on top of the SSRF guard in
6
+ * server/utils/safe-fetch.js, this module enforces:
7
+ *
8
+ * 1. Scheme allow-list (https only by default).
9
+ * 2. TLD/host denylist (configurable via WAB_URL_DENY_HOSTS / DEFAULT_DENY).
10
+ * 3. Path denylist for obvious admin/credential/wp-login style targets that
11
+ * would suggest abuse.
12
+ * 4. Per-actor (IP / API-key / siteId) rate-limit independent of express
13
+ * router-level rate limiting.
14
+ *
15
+ * Decisions are recorded in `url_policy_audit` for review.
16
+ */
17
+
18
+ const crypto = require('crypto');
19
+ const { db } = require('../models/db');
20
+
21
+ const DEFAULT_DENY_HOSTS = [
22
+ // Local/private/metadata is already blocked by safe-fetch; these are
23
+ // additional public hosts that have no legitimate scraping use case.
24
+ 'login.microsoftonline.com',
25
+ 'accounts.google.com',
26
+ 'appleid.apple.com',
27
+ ];
28
+
29
+ const DEFAULT_DENY_PATH_RE = /\/(?:wp-(?:login|admin)|administrator|phpmyadmin|\.git|\.env)(?:\/|\.|$|\?)/i;
30
+
31
+ const RATE_WINDOW_MS = 60_000;
32
+ const RATE_MAX = parseInt(process.env.WAB_URL_POLICY_RATE_MAX || '30', 10);
33
+
34
+ function _envHosts() {
35
+ return String(process.env.WAB_URL_DENY_HOSTS || '')
36
+ .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
37
+ }
38
+
39
+ db.exec(`
40
+ CREATE TABLE IF NOT EXISTS url_policy_audit (
41
+ id TEXT PRIMARY KEY,
42
+ actor TEXT,
43
+ url TEXT,
44
+ decision TEXT NOT NULL CHECK(decision IN ('allowed','blocked','rate_limited')),
45
+ reason TEXT,
46
+ created_at TEXT DEFAULT (datetime('now'))
47
+ );
48
+ CREATE INDEX IF NOT EXISTS idx_urlpolicy_decision ON url_policy_audit(decision);
49
+ `);
50
+
51
+ const _rate = new Map(); // actor → [ts]
52
+
53
+ function _hit(actor) {
54
+ const now = Date.now();
55
+ const arr = (_rate.get(actor) || []).filter((t) => now - t < RATE_WINDOW_MS);
56
+ arr.push(now);
57
+ _rate.set(actor, arr);
58
+ return arr.length;
59
+ }
60
+
61
+ function _audit(actor, url, decision, reason) {
62
+ try {
63
+ db.prepare(`INSERT INTO url_policy_audit (id, actor, url, decision, reason)
64
+ VALUES (?, ?, ?, ?, ?)`).run(
65
+ crypto.randomUUID(), actor || null, url || null, decision, reason || null);
66
+ } catch (_) { /* never block on audit failure */ }
67
+ }
68
+
69
+ /**
70
+ * @param {string} rawUrl
71
+ * @param {object} opts
72
+ * @param {string} [opts.actor] - IP, API key id, or site id
73
+ * @returns {{ ok:boolean, reason?:string, code?:string, parsed?:URL }}
74
+ */
75
+ function check(rawUrl, opts = {}) {
76
+ const actor = opts.actor || 'anon';
77
+
78
+ if (typeof rawUrl !== 'string' || rawUrl.length === 0) {
79
+ _audit(actor, String(rawUrl).slice(0, 200), 'blocked', 'missing_url');
80
+ return { ok: false, reason: 'URL is required', code: 'MISSING_URL' };
81
+ }
82
+ if (rawUrl.length > 2048) {
83
+ _audit(actor, rawUrl.slice(0, 200), 'blocked', 'url_too_long');
84
+ return { ok: false, reason: 'URL exceeds 2048 characters', code: 'URL_TOO_LONG' };
85
+ }
86
+
87
+ let parsed;
88
+ try { parsed = new URL(rawUrl); }
89
+ catch {
90
+ _audit(actor, rawUrl.slice(0, 200), 'blocked', 'invalid_url');
91
+ return { ok: false, reason: 'Invalid URL', code: 'INVALID_URL' };
92
+ }
93
+
94
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
95
+ _audit(actor, rawUrl, 'blocked', `scheme:${parsed.protocol}`);
96
+ return { ok: false, reason: `Scheme ${parsed.protocol} not allowed`, code: 'BAD_SCHEME' };
97
+ }
98
+ if (process.env.WAB_URL_POLICY_HTTPS_ONLY === '1' && parsed.protocol !== 'https:') {
99
+ _audit(actor, rawUrl, 'blocked', 'http_disallowed');
100
+ return { ok: false, reason: 'HTTPS required', code: 'HTTPS_REQUIRED' };
101
+ }
102
+
103
+ const host = parsed.hostname.toLowerCase();
104
+ const deny = new Set([...DEFAULT_DENY_HOSTS, ..._envHosts()]);
105
+ if (deny.has(host)) {
106
+ _audit(actor, rawUrl, 'blocked', `host_denied:${host}`);
107
+ return { ok: false, reason: `Host ${host} is denied by policy`, code: 'HOST_DENIED' };
108
+ }
109
+
110
+ if (DEFAULT_DENY_PATH_RE.test(parsed.pathname)) {
111
+ _audit(actor, rawUrl, 'blocked', `path_denied:${parsed.pathname}`);
112
+ return { ok: false, reason: 'Path matches abuse pattern', code: 'PATH_DENIED' };
113
+ }
114
+
115
+ const count = _hit(actor);
116
+ if (count > RATE_MAX) {
117
+ _audit(actor, rawUrl, 'rate_limited', `count:${count}`);
118
+ return { ok: false, reason: `Rate limit exceeded (${RATE_MAX} URLs/min per actor)`, code: 'RATE_LIMITED' };
119
+ }
120
+
121
+ _audit(actor, rawUrl, 'allowed', null);
122
+ return { ok: true, parsed };
123
+ }
124
+
125
+ function getRecentAudits(limit = 100, decision) {
126
+ if (decision) {
127
+ return db.prepare(`SELECT * FROM url_policy_audit WHERE decision = ? ORDER BY rowid DESC LIMIT ?`).all(decision, limit);
128
+ }
129
+ return db.prepare(`SELECT * FROM url_policy_audit ORDER BY rowid DESC LIMIT ?`).all(limit);
130
+ }
131
+
132
+ function actorFromReq(req) {
133
+ return (req.wabAuth && req.wabAuth.key_id) ||
134
+ (req.user && req.user.id) ||
135
+ req.ip ||
136
+ 'anon';
137
+ }
138
+
139
+ module.exports = { check, getRecentAudits, actorFromReq, RATE_MAX };