@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.41.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 (123) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/README.md +18 -6
  3. package/SECURITY.md +27 -6
  4. package/bin/fabric-agent-adapter.js +71 -6
  5. package/package.json +2 -2
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +104 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
  102. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  103. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  104. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
  105. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
  106. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
  107. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
  108. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  109. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
  110. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
  115. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
  116. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
  117. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
  118. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
  121. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
  122. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
  123. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
@@ -30,7 +30,7 @@ the declaration (`auth.delegation-declared`) and that `/mcp` refuses the descrip
30
30
 
31
31
  ```python
32
32
  import fabric_delegation as fd, fabric_interop as fi
33
- keys = fd.JwksCache(well_known["auth"]["delegation"]["jwks"]) # https, loopback http, or a file
33
+ keys = fd.JwksCache(well_known["auth"]["delegation"]["jwks"]) # https, loopback http, or a file; one 5 s deadline per fetch
34
34
  gate = fd.delegation_gate(audience="example-agent", jwks=keys, issuer=well_known["auth"]["delegation"]["issuer"], # required
35
35
  effects={"example.write": "draft", "example.publish": "publish"}) # each tool's manifest effect
36
36
  server = fi.McpToolServer("example-agent", VERSION, authorize=gate) # pass the request headers to handle()
@@ -115,7 +115,8 @@ current verified revision as `estate=` / `{ estate }`, as a value or as a functi
115
115
  - A revision that cannot be read, is outside its window, is lower than the applied one or forks it is the trust
116
116
  source's fault: `issuer-unavailable`, detail `estate-unreadable`, `estate-outside-window`, `estate-rolled-back` or
117
117
  `estate-forked`.
118
- - Without `estate`, nothing changes.
118
+ - Without `estate` (the option left out), nothing changes. An explicit `null` (Python `None`) for `estate`, its
119
+ applied mark or its revocations is an unreadable source, `estate-unreadable`, in both kits (FAA-78).
119
120
  - Every verifier refuses a clock that is not a number of seconds `unknown-state` `no-clock` before row 1: a NaN clock
120
121
  would make every time row false and pass an expired token.
121
122
 
@@ -87,8 +87,8 @@ gate = fd.delegation_gate(audience=..., jwks=keys, issuer=..., effects=..., esta
87
87
  `estate-forked` (the applied number with another digest). A caller cannot cure these by minting again.
88
88
  - An unreadable clock (not a finite number of seconds a `Date` can hold) is refused `unknown-state` `no-clock`
89
89
  before row 1, in every verifier.
90
- - Only an absent `estate` skips the check. In the Node kit `null` is an unreadable source; in Python, where `None` is
91
- the absent value, pass a function returning `None` for a source that has nothing.
90
+ - Only an absent `estate` skips the check: an option left out. An explicit `null` (Python `None`) is an unreadable
91
+ source in both kits, and so is `applied: null`; an omitted `applied` means no revision applied yet (FAA-78).
92
92
  - **What you pass in.** Pass only a revision that `verify_estate_record` accepted and whose number is not lower than
93
93
  the highest the machine applied; a higher one only when you verified its chain from the applied revision.
94
94
  - `hub_key_certified(record, kid, x, at, applied, extra_revocations)` /
@@ -120,8 +120,9 @@ of `[A-Za-z0-9][A-Za-z0-9_-]{0,127}`. Absent is `personal`; `null` is not absent
120
120
  `publisherKeys` pins. A key no scope names keeps DEC-0051's meaning, trusted in any context.
121
121
  - **Update a copied kit before a revision carries `publisherScopes`.** A service runs the copy of the kit it was built
122
122
  with; a copy older than 0.39.0 does not read `publisherScopes`, so it trusts a partner's key in every context and
123
- finds no rule broken. A service that applies the estate record updates its copy of `fabric_estate.py` /
124
- `fabric-estate.mjs` to 0.39.0 or later first.
123
+ finds no rule broken. A service that applies the estate record updates its copy first:
124
+ `fabric_kit.py install <dir> --module fabric_estate` (or `--lang node --module fabric-estate`). Since 0.40.0 the
125
+ estate reader imports the kit's core modules, so the one file alone no longer loads ([kit modules](kit-modules.md)).
125
126
 
126
127
  Every rule is total on hostile JSON, as the contract's are: an `instancePath` names an item by its index as written (a
127
128
  non-object before it does not shift it), and a value whose own `toString` member is data is shown as its JSON. Both kits
@@ -103,6 +103,11 @@ As a provider:
103
103
  caller only. A key that already names a job starts nothing; `jobs.repeat(caller, key, now)` answers `new`, `same`
104
104
  (answer with that job) or `run`: a `quota-exhausted` job runs again once its reset passed or you pass
105
105
  `limit_holds=False`, a `limit-reached` job once `approval_granted=True`, and an `outcome-unknown` job never.
106
+ A key admits only the intent it was first used for: the key file keeps a digest of the capability and its input,
107
+ and the same key with another intent is refused as `idempotency-conflict` (FAA-77). `repeat` takes the intent too
108
+ (`capability=`, `arguments=`; Node `{capability, input}`) and then answers `conflict` for another intent. A key file
109
+ written before 0.41.0 holds no intent: it is answered with its job, never a conflict, and its job runs again only
110
+ for the capability and input that job stored.
106
111
  - **Usage you did not measure is `null`, never 0,** but only toward a caller whose request `_meta.protocols` names
107
112
  `fabric-interop/0.2`: pass `request_meta` to `result_envelope` (`usage_toward`). Toward any other caller the kit
108
113
  writes the measured lower bound, as before. Either way, record that call's usage receipt with
@@ -0,0 +1,129 @@
1
+ # The service kit: modules, layers and installing them
2
+
3
+ Since 0.40.0 (FAA-65) the kit is a set of small modules in two languages, not two large files. `scripts/kit.json`
4
+ lists every module in dependency order, per language, with the kit modules it imports and its sha256; `history` keeps
5
+ the digests of every release since 0.8.0, so a copy in a service can be told apart. `scripts/fabric_kit.py` installs a
6
+ module with exactly what it needs and reports a copy's state. Tests: `test/test_fabric_kit.py`,
7
+ `test/test_kit_api.py`, `test/kit-api.test.mjs`.
8
+
9
+ ## Contents
10
+
11
+ - [Install, status, build](#install-status-build)
12
+ - [The layers](#the-layers)
13
+ - [What 0.39 code keeps](#what-039-code-keeps)
14
+ - [Dates and times](#dates-and-times)
15
+ - [Adding or changing a module](#adding-or-changing-a-module)
16
+
17
+ ## Install, status, build
18
+
19
+ ```bash
20
+ K=<adapter>/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts
21
+ python3 $K/fabric_kit.py install <service-dir> # fabric_service and its closure (Python)
22
+ python3 $K/fabric_kit.py install <service-dir> --module fabric_service --module fabric_interop --module fabric_board
23
+ # what sample_service.py imports
24
+ python3 $K/fabric_kit.py install <service-dir> --lang node # fabric-service.mjs and its closure
25
+ python3 $K/fabric_kit.py install <service-dir> --module fabric_openrouter --module fabric_board
26
+ python3 $K/fabric_kit.py install <service-dir> --all --dry-run # every module, nothing written
27
+ python3 $K/fabric_kit.py status <service-dir> [--json] # current, older (the release), modified, missing
28
+ python3 $K/fabric_kit.py build [--check] # maintainers: refresh kit.json from the sources
29
+ ```
30
+
31
+ - `install` copies the closure of the named modules and nothing else. It refuses to overwrite a file it does not
32
+ recognise (`modified`: someone edited the copy) unless `--force`, and says which.
33
+ - `status` names each kit file in the folder as `current`, `older` with the release it came from, or `modified`, and
34
+ prints `missing <file>, which <file> imports` for every module the folder lacks, read from what each copy itself
35
+ imports and followed through the modules it would install: that copy would fail when that code path runs. A folder
36
+ holding only the 0.40.0 `fabric_service.py` gets one line for each of the 16 modules it needs; a 0.39.1 copy is
37
+ asked only for what it imports (`test_an_older_copy_is_judged_by_its_own_imports`).
38
+ - Exit codes: 0 done or every copy current, 1 a problem (a copy missing, `older` or `modified`, no kit module in the
39
+ folder at all, an install refused), 2 usage (an unknown `--module` included).
40
+ - Every kit module puts its own folder on `sys.path` before it imports a sibling, so a service that loads a kit file
41
+ by its path from another working directory gets its siblings too (`test_every_module_loads_by_path_from_any_folder`).
42
+ - `build --check` is what CI runs: `kit.json` must be what the sources say.
43
+
44
+ ## The layers
45
+
46
+ A module imports only modules listed before it in `kit.json`, so the kit has no import cycle and no upward import
47
+ (`test_the_order_is_a_layering`; a planted cycle is caught). Python name first, the Node twin is the same name with
48
+ `-` for `_` and `.mjs`. No module is over 1000 lines (`test_no_module_over_the_line_ceiling`).
49
+
50
+ | Layer | Module | What it holds |
51
+ |---|---|---|
52
+ | L0 | `fabric_jsvalue` | JavaScript's view of JSON values: numbers, strings, equality, `jcs`, `show`; defined once for the kit |
53
+ | L0 | `fabric_time` | strict RFC 3339, UTC days, formatting, day windows, the clock ([dates](#dates-and-times)) |
54
+ | L0 | `fabric_identifiers` | identifier equality by normal form (DEC-0076) |
55
+ | L0 | `fabric_secrets` | the contract's one credential detector (DEC-0044) |
56
+ | L1 | `fabric_crypto` | Ed25519 signing (a Montgomery ladder that does not branch on the key) and verification, P-256 verification, base64url, sha256 |
57
+ | L2 | `fabric_estate` | `estate-record/1` revisions and their rules (DEC-0055) |
58
+ | L2 | `fabric_delegation` | the callee's side of `fabric-delegation/0.1` (DEC-0039) |
59
+ | L2 | `fabric_share` | share grants and registrations (DEC-0086) |
60
+ | L2 | `fabric_ceremonies` | the headless join token (DEC-0084) |
61
+ | L2 | `fabric_package` | `fabric-package/1`: pack, sign, verify (DEC-0050, DEC-0051) |
62
+ | L3 | `fabric_base` | the protocol name, `ServiceError`, small shared helpers |
63
+ | L3 | `fabric_platform` | Windows: named pipes, owner-only files (DEC-0032) |
64
+ | L3 | `fabric_paths` | the services directory, a service's folders, atomic and private writes |
65
+ | L3 | `fabric_protocols` | protocol revisions (DEC-0067) and feed data (DEC-0070) |
66
+ | L3 | `fabric_lock` | the instance lock and the supervisor marker (LC-01, DEC-0057) |
67
+ | L3 | `fabric_usage` | usage receipts, the ledger, the usage report, attribution, spending limits ([usage](usage.md)) |
68
+ | L3 | `fabric_backups` | settings backups (DEC-0025) |
69
+ | L3 | `fabric_descriptor` | the descriptor, peers, app descriptors, the well-known document |
70
+ | L3 | `fabric_auth` | the token, the network guard, login codes |
71
+ | L3 | `fabric_events` | events, paging, the JSONL event log (DEC-0029) |
72
+ | L3 | `fabric_logs` | rotating logs, the stop signal and the drain (LC-12) |
73
+ | L3 | `fabric_supervisors` | launchd, systemd user units, Task Scheduler (DEC-0032, LC-14) |
74
+ | L3 | `fabric_service` | the facade: every name 0.39.1 exported, re-exported from the modules above |
75
+ | L3 | `fabric_profiles` | runner and model profiles ([profiles](profiles.md)) |
76
+ | L3 | `fabric_interop` | `fabric-interop/0.1`: jobs, envelopes, MCP tools ([interop](interop.md)) |
77
+ | L4 | `fabric_hub` | the Fabric hub's door (`fabric-hub/0.1`) |
78
+ | L4 | `fabric_board_shapes` | the project board's wire: constants, refusal codes, shapes |
79
+ | L4 | `fabric_board_link` | the board's link: the MCP session over the hub, the fsynced checkpoint, a claimed request as data |
80
+ | L4 | `fabric_board_session` | the board responder: the work context, the fenced request, transfer and recovery |
81
+ | L4 | `fabric_board_fixture` | the scripted board the probe runs a responder against |
82
+ | L4 | `fabric_board` | the facade of the board connector ([board](board.md)) |
83
+ | L4 | `fabric_http` | outbound HTTP: deadline, bounded retries, `Retry-After`, size cap ([connectors](connectors.md)) |
84
+ | L4 | `fabric_openrouter` | OpenRouter chat: own key, spend ceiling, receipts, fallback ([connectors](connectors.md)) |
85
+
86
+ Beside the kit, not in `kit.json`: the probe (`check_service.py`, `check_service_rules.py`), the sample
87
+ (`sample_service.py`, `sample-remote-service.mjs`), `fabric_kit.py` itself and the dashboard tools. Recipes built on
88
+ the kit live in `../recipes/` ([the support agent](../recipes/support-agent/README.md)).
89
+
90
+ ## What 0.39 code keeps
91
+
92
+ `import fabric_service as fs` and `import ... from './fabric-service.mjs'` keep working: the facades re-export every
93
+ public name 0.39.1 had, in both languages (the snapshot is `test/fixtures/kit-api/kit-api-0.39.1.json`; a missing
94
+ name fails `test_kit_api`), and the Python facade the private names other code reached through it (`_number`,
95
+ `_DAY_MS`, `_utc_date_ms` from the core). `fabric_board` and `fabric-board.mjs` are
96
+ facades the same way. What changed for a service that copies the kit: `fabric_service.py` alone is no longer enough;
97
+ install with `fabric_kit.py install` and `status` names what is missing. A test or service that replaced a function
98
+ by assigning it on the facade (`fabric_service.X = ...`) no longer changes what the kit does: the code reads its own
99
+ module's name, so patch the module that defines it (`fabric_lock.X`, `fabric_session_env.X`). A service that imported a module-private
100
+ helper (`_x`) of the Node twin should import it from its module now.
101
+
102
+ ## Dates and times
103
+
104
+ `fabric_time` / `fabric-time.mjs` is the kit's one reading of dates, on the cases both twins test
105
+ (`test/fixtures/kit-time-cases.json`). Anything unreadable is NaN, never an exception, so a check compares it and
106
+ reports a finding.
107
+
108
+ | Python | Node | What |
109
+ |---|---|---|
110
+ | `parse_rfc3339_ms(text)` | `parseRfc3339Ms(text)` | strict RFC 3339: a `T`, an offset and a day that exists; no space, no leap second; milliseconds or NaN |
111
+ | `utc_date_ms(text)` | `utcDateMs(text)` | a `YYYY-MM-DD` that exists, as midnight UTC in milliseconds, or NaN |
112
+ | `utc_day(ms)` | `utcDay(ms)` | the UTC calendar day of an instant (NaN or Infinity raise) |
113
+ | `format_rfc3339(ms)` | `formatRfc3339(ms)` | `2026-10-21T09:00:00.000Z`, which parses back to the same value |
114
+ | `day_window(today_ms, days)` | `dayWindow(todayMs, days)` | the last N UTC days ending with today's, oldest first (a usage report's 31) |
115
+ | `clock_ms(at)` | `clockMs(at)` | a verifier's clock (an aware `datetime` / a `Date`, or epoch seconds) in milliseconds |
116
+ | `now_ms(clock)` | `nowMs(clock)` | now in milliseconds; `clock` returns seconds in Python (`time.time`), milliseconds in Node (`Date.now`) |
117
+ | `DAY_MS` | `DAY_MS` | 86 400 000 |
118
+
119
+ `fabric_estate.estate_time` is `parse_rfc3339_ms` under its older name.
120
+
121
+ ## Adding or changing a module
122
+
123
+ 1. Put it in the lowest layer whose modules it needs; import only modules listed before it.
124
+ 2. Write the Node twin with the same public names in camelCase, and shared cases under `test/fixtures/` that both
125
+ test files read.
126
+ 3. Add it to `kit.json` (module, file, layer) and run `fabric_kit.py build`; add it to the table above
127
+ (`test_every_kit_module_is_documented`).
128
+ 4. A value or helper the core already has is imported, never redefined (`test_the_core_is_defined_once`).
129
+ 5. Stay under 1000 lines; split by responsibility, keeping a facade when callers already import the old name.
@@ -273,7 +273,9 @@ that still exits 75 stays down until the next logon.
273
273
  a named pipe on Windows (`lock_name` / `lockName`), each gone with the process. The Python kit
274
274
  holds the same name beside its file lock, so the two kits exclude each other everywhere, and
275
275
  `check_service.py` reports the lock held when either is. The probe only looks (`/proc/net/unix`,
276
- `WaitNamedPipeW`); it never takes the name, so it cannot make a service that is starting fail.
276
+ `WaitNamedPipeW`); it never takes the name, so it cannot make a service that is starting fail. On Linux the Node kit's
277
+ name needs Node 22 or later: Node 18 and early 20 collapse every abstract socket name into one, and later 20 pads it so
278
+ no Python copy or probe sees it, so the Node kit refuses to start there with a sentence naming Node 22 (FAA-72).
277
279
 
278
280
  **Token files on Windows.** The privacy of `service.token` is its ACL (service.md *Windows
279
281
  token files*): the kits write a protected list: the user, SYSTEM and Administrators, set before the
@@ -50,7 +50,11 @@ copy. A member named twice in `fabric.package.json` is refused too.
50
50
  Then every problem is listed, each with its `rule`: `schema` (the contract's schema), `FAC-SEM-064` (whole: its own
51
51
  digest, each path once, every named file listed with the same SHA-256), `FAC-SEM-065` (no secret), `FAC-SEM-066`
52
52
  (`agentRef` derived), `FAC-SEM-102` (one role, one server) and `folder` (`file-missing`, `file-digest`, `file-unlisted`,
53
- `plugin-manifest`, `provider-manifest`). With a problem, `--apply` writes nothing. Repacking drops old signatures and
53
+ `plugin-manifest`, `provider-manifest`) and `path` (a listed or folder path holding a control character, U+2028 or
54
+ U+2029, a `..` segment, a backslash, `//` or a colon, refused by the kit itself whatever the schema's pattern lets
55
+ through, FAA-78). A JSON document is read as strict UTF-8 and nested at most 256 levels; a folder entry the kit cannot
56
+ read is refused as `Cannot read <path>`, and a link, FIFO or other non-regular file is never followed. With a problem,
57
+ `--apply` writes nothing. Repacking drops old signatures and
54
58
  says so: a changed package is signed again.
55
59
 
56
60
  ## sign
@@ -29,6 +29,8 @@ ledger.record(receipt) # fs.JsonlUsageLedger(data / "usage.j
29
29
  ledger.record(k.makeUsageReceipt('openrouter', model, { inputTokens, outputTokens, costUsd: usage.cost ?? null }));
30
30
  ```
31
31
 
32
+ - **OpenRouter through the kit.** `fabric_openrouter.Client` records these receipts itself, one per answered call,
33
+ and checks a spend ceiling before every attempt ([connectors](connectors.md)).
32
34
  - **The provider's own numbers.** Take tokens from the response's usage block, not from a local
33
35
  estimate. The cost is `provider` when the provider reported the charge (OpenRouter `usage.cost`,
34
36
  a billing API) and `price-list` when you computed it from a published price list.
@@ -22,6 +22,13 @@ import { pathToFileURL } from 'node:url';
22
22
 
23
23
  const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1';
24
24
  const RULES = ['ED-1', 'ED-2', 'ED-3', 'ED-4', 'ED-5'];
25
+ // The most elements ED-4 and ED-5 walk on one page; past it they say how much they did not see.
26
+ const ELEMENT_CAP = 6000;
27
+
28
+ // A one-time login code is a credential: no report, error or evidence line carries one.
29
+ export function scrub(text) {
30
+ return String(text).replace(/([?&#;]code=)[^&#\s"']*/gi, '$1<redacted>');
31
+ }
25
32
 
26
33
  function usage(message) {
27
34
  if (message) process.stderr.write(message + '\n');
@@ -103,7 +110,7 @@ async function serviceTarget(spec, dir) {
103
110
  }
104
111
 
105
112
  // Runs inside the page. Everything it reports is measured from the rendered layout.
106
- function measure(floor) {
113
+ function measure([floor, cap]) {
107
114
  const de = document.documentElement;
108
115
  const vw = de.clientWidth;
109
116
  const vh = window.innerHeight;
@@ -111,6 +118,9 @@ function measure(floor) {
111
118
  const cs = (el) => getComputedStyle(el);
112
119
  const scrollsX = (el) => /(auto|scroll)/.test(cs(el).overflowX);
113
120
  const clipsX = (el) => cs(el).overflowX !== 'visible';
121
+ // Vertically a scroll box (auto, scroll) still lets the reader reach what it holds; hidden and clip do not.
122
+ const clipsY = (el) => /(hidden|clip)/.test(cs(el).overflowY);
123
+ const pinned = (el) => { for (let a = el; a && a !== de; a = a.parentElement) if (cs(a).position === 'fixed') return true; return false; };
114
124
  const label = (el) => {
115
125
  const id = el.id ? '#' + el.id : '';
116
126
  const cls = typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/).join('.') : '';
@@ -118,16 +128,22 @@ function measure(floor) {
118
128
  };
119
129
  const visible = (el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 && cs(el).visibility !== 'hidden'; };
120
130
 
121
- // ED-3: a link is reachable when it is fully inside the viewport and not clipped by an ancestor,
122
- // or when it sits in a closed <details> whose summary is reachable, or in a menu a visible
131
+ // ED-3: a link is reachable when it is fully inside the viewport's width, inside the page's height
132
+ // (the viewport's, for something fixed to it) and not clipped by an ancestor on either axis, or
133
+ // when it sits in a closed <details> whose summary is reachable, or in a menu a visible
123
134
  // aria-controls button opens.
124
135
  const inViewport = (el) => {
125
136
  const r = el.getBoundingClientRect();
126
137
  if (r.width <= 0 || r.height <= 0 || r.left < -1 || r.right > vw + 1) return false;
138
+ const [top, bottom] = pinned(el) ? [0, vh] : [-window.scrollY, de.scrollHeight - window.scrollY];
139
+ if (r.top < top - 1 || r.bottom > bottom + 1) return false;
127
140
  for (let a = el.parentElement; a && a !== de; a = a.parentElement) {
128
- if (!clipsX(a)) continue;
141
+ const x = clipsX(a);
142
+ const y = clipsY(a);
143
+ if (!x && !y) continue;
129
144
  const ar = a.getBoundingClientRect();
130
- if (r.left < ar.left - 1 || r.right > ar.right + 1) return false;
145
+ if (x && (r.left < ar.left - 1 || r.right > ar.right + 1)) return false;
146
+ if (y && (r.top < ar.top - 1 || r.bottom > ar.bottom + 1)) return false;
131
147
  }
132
148
  return true;
133
149
  };
@@ -155,9 +171,11 @@ function measure(floor) {
155
171
  // unless it is inside a scroll box or clipped by an ancestor that is itself inside the viewport.
156
172
  const all = document.body ? document.body.querySelectorAll('*') : [];
157
173
  const FOCUSABLE = 'a[href], button, input, select, textarea, [tabindex]';
174
+ out.elements = all.length;
175
+ out.measured = Math.min(all.length, cap);
158
176
  let n = 0;
159
177
  for (const el of all) {
160
- if (++n > 6000) break;
178
+ if (++n > cap) break;
161
179
  if (!visible(el)) continue;
162
180
  if (scrollsX(el) && el.scrollWidth > el.clientWidth + 1) {
163
181
  const keyboard = el.tabIndex >= 0 || !!el.querySelector(FOCUSABLE);
@@ -214,21 +232,28 @@ export function verdicts(width, floor, m) {
214
232
  else add('ED-3', m.nav.unreachable.length ? 'FAIL' : 'PASS',
215
233
  m.nav.unreachable.length ? `${m.nav.unreachable.length} of ${m.nav.total} links unreachable: ${m.nav.unreachable.slice(0, 6).join(', ')}` : `${m.nav.total} links reachable`);
216
234
  const badBoxes = m.boxes.filter((b) => !b.keyboard || !b.named);
235
+ // A page larger than the walk: what the walk saw can fail, but it cannot pass for the whole page.
236
+ const capped = m.elements > m.measured ? `only the first ${m.measured} of ${m.elements} elements measured` : '';
217
237
  const problems = [];
218
238
  if (m.past.length) problems.push(`past the viewport, not in a scroll box: ${m.past.slice(0, 4).join(', ')}`);
219
239
  if (badBoxes.length) problems.push(`scroll box without keyboard access or a name: ${badBoxes.slice(0, 4).map((b) => b.el).join(', ')}`);
220
- add('ED-4', problems.length ? 'FAIL' : 'PASS', problems.join('; ') || `${m.boxes.length} overflowing box(es), each keyboard-reachable; edge shades not measured`);
240
+ if (problems.length) add('ED-4', 'FAIL', problems.join('; ') + (capped ? `; ${capped}` : ''));
241
+ else if (capped) add('ED-4', 'NOT_RUN', `${capped}: ${m.boxes.length} overflowing box(es) keyboard-reachable among them, the rest not seen`);
242
+ else add('ED-4', 'PASS', `${m.boxes.length} overflowing box(es), each keyboard-reachable; edge shades not measured`);
221
243
  const limit = Math.min(120, m.vh / 4);
222
244
  const ed5 = [];
223
245
  if (m.chrome > limit) ed5.push(`sticky chrome ${m.chrome} px > ${Math.round(limit)} px`);
224
246
  if (m.nested.length) ed5.push(`nested full-height scroller: ${m.nested.slice(0, 3).join(', ')}`);
225
247
  if (m.anchor && m.anchor.top < m.chrome - 1) ed5.push(`anchor ${m.anchor.el} lands at ${m.anchor.top} px, under the ${m.chrome} px chrome`);
226
248
  add('ED-5', ed5.length ? 'FAIL' : 'PASS', ed5.join('; ') ||
227
- `sticky chrome ${m.chrome} px ≤ ${Math.round(limit)} px` + (m.anchor ? `; anchor lands at ${m.anchor.top} px` : '; no in-page anchor to jump to') + '; refresh keeps scroll: not measured');
249
+ `sticky chrome ${m.chrome} px ≤ ${Math.round(limit)} px` + (m.anchor ? `; anchor lands at ${m.anchor.top} px` : '; no in-page anchor to jump to') + '; refresh keeps scroll: not measured' +
250
+ (capped ? `; nested scrollers: ${capped}` : ''));
228
251
  return rows;
229
252
  }
230
253
 
231
254
  function report(rows, opts, target) {
255
+ rows = rows.map((r) => ({ ...r, evidence: scrub(r.evidence) }));
256
+ target = scrub(target);
232
257
  const failed = rows.filter((r) => r.verdict === 'FAIL').length;
233
258
  if (opts.json) {
234
259
  process.stdout.write(JSON.stringify({ target, browser: opts.browser, results: rows, failed }, null, 2) + '\n');
@@ -241,27 +266,31 @@ function report(rows, opts, target) {
241
266
 
242
267
  async function main(argv) {
243
268
  const opts = parseArgs(argv);
269
+ // What the report names: the service, never the login URL its one-time code is in.
270
+ const shown = opts.service || opts.url;
244
271
  let target = opts.url;
245
272
  let floor = opts.floor;
246
- const notRun = (why) => report(RULES.map((rule) => ({ rule, width: 0, verdict: 'NOT_RUN', evidence: why })), opts, target || opts.service);
273
+ const notRun = (why) => report(RULES.map((rule) => ({ rule, width: 0, verdict: 'NOT_RUN', evidence: why })), opts, shown);
274
+ // The browser first: a run that cannot measure must not spend a one-time code.
275
+ const pw = await loadPlaywright(opts.playwright);
276
+ if (!pw) return notRun('Playwright is not installed here: npm i -D playwright && npx playwright install webkit (or pass --playwright DIR)');
277
+ let browser;
278
+ try {
279
+ browser = await pw[opts.browser].launch();
280
+ } catch (err) {
281
+ return notRun(`the ${opts.browser} browser does not start: ${String(err.message).split('\n')[0]} (npx playwright install ${opts.browser})`);
282
+ }
247
283
  if (opts.service) {
248
284
  try {
249
285
  const t = await serviceTarget(opts.service, opts.servicesDir);
250
286
  target = t.url;
251
287
  if (floor === undefined && Number.isInteger(t.declared?.minWidth)) floor = t.declared.minWidth;
252
288
  } catch (err) {
289
+ await browser.close();
253
290
  return notRun(`could not open the service's dashboard: ${err.message}`);
254
291
  }
255
292
  }
256
293
  floor = floor ?? 360;
257
- const pw = await loadPlaywright(opts.playwright);
258
- if (!pw) return notRun('Playwright is not installed here: npm i -D playwright && npx playwright install webkit (or pass --playwright DIR)');
259
- let browser;
260
- try {
261
- browser = await pw[opts.browser].launch();
262
- } catch (err) {
263
- return notRun(`the ${opts.browser} browser does not start: ${String(err.message).split('\n')[0]} (npx playwright install ${opts.browser})`);
264
- }
265
294
  const rows = [];
266
295
  try {
267
296
  const context = await browser.newContext({ viewport: { width: Math.max(...opts.widths), height: opts.height } });
@@ -279,7 +308,7 @@ async function main(argv) {
279
308
  await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
280
309
  await page.waitForTimeout(250);
281
310
  await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
282
- const m = await page.evaluate(measure, floor);
311
+ const m = await page.evaluate(measure, [floor, ELEMENT_CAP]);
283
312
  rows.push(...verdicts(width, floor, m));
284
313
  }
285
314
  } catch (err) {
@@ -287,7 +316,7 @@ async function main(argv) {
287
316
  } finally {
288
317
  await browser.close();
289
318
  }
290
- return report(rows, opts, opts.service || target);
319
+ return report(rows, opts, shown);
291
320
  }
292
321
 
293
322
  if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
@@ -7,7 +7,7 @@ This is an output correctness gate, not authentication, discovery, or a relay.
7
7
  import json
8
8
  import re
9
9
  import sys
10
- from urllib.parse import parse_qs, urlsplit
10
+ from urllib.parse import parse_qs, parse_qsl, unquote, urlsplit
11
11
 
12
12
 
13
13
  def require(condition, reason):
@@ -15,6 +15,63 @@ def require(condition, reason):
15
15
  raise ValueError(reason)
16
16
 
17
17
 
18
+ # Credential names and values: the same rules as fabric-button.js (credentialName, secretValue).
19
+ CREDENTIAL_WORDS = {
20
+ 'token', 'secret', 'password', 'passwd', 'pass', 'passphrase', 'credential', 'credentials', 'cookie',
21
+ 'authorization', 'bearer', 'jwt', 'otp', 'apikey', 'signature', 'sig', 'accesstoken', 'idtoken', 'authtoken',
22
+ 'refreshtoken', 'sessionid', 'sessiontoken', 'logincode', 'authcode', 'privatekey', 'clientsecret', 'secretkey',
23
+ 'accesskey'}
24
+ CREDENTIAL_PAIRS = {
25
+ 'key': {'private', 'api', 'access', 'secret', 'session', 'auth', 'signing', 'encryption', 'master', 'client', 'login',
26
+ 'refresh'},
27
+ 'auth': {'token', 'code', 'header'},
28
+ 'session': {'token', 'id'},
29
+ 'code': {'login', 'auth', 'access', 'session'}}
30
+ JWT_SHAPE = re.compile(r'eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*')
31
+ SECRET_VALUE = re.compile(r'(^bearer\s.+|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}'
32
+ r'|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})', re.I | re.S)
33
+ LONG_RUN = re.compile(r'[A-Za-z0-9+/=_-]{40,}')
34
+
35
+
36
+ def name_words(name):
37
+ name = re.sub(r'([a-z0-9])([A-Z])', r'\1 \2', name)
38
+ name = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1 \2', name)
39
+ return [w.lower() for w in re.split(r'[\s._-]+', name) if w]
40
+
41
+
42
+ def credential_name(name):
43
+ split = name_words(name)
44
+ words = set(split) | {w[:-1] for w in split if len(w) > 2 and w.endswith('s')}
45
+ if words & CREDENTIAL_WORDS:
46
+ return True
47
+ if len(split) == 1 and words & set(CREDENTIAL_PAIRS):
48
+ return True
49
+ return any(w in words and words & pairs for w, pairs in CREDENTIAL_PAIRS.items())
50
+
51
+
52
+ def secret_value(value):
53
+ long_secret = any(re.search(r'[a-z]', run) and re.search(r'[A-Z]', run) and re.search(r'[0-9]', run)
54
+ for run in LONG_RUN.findall(value))
55
+ return bool(JWT_SHAPE.search(value) or SECRET_VALUE.search(value) or long_secret)
56
+
57
+
58
+ def carries_credential(text):
59
+ # A query or fragment whose names or values could be a credential: refused even with an empty value.
60
+ return (secret_value(text) or secret_value(unquote(text))
61
+ or any(credential_name(name) or secret_value(name) or secret_value(value)
62
+ for name, value in parse_qsl(text, keep_blank_values=True)))
63
+
64
+
65
+ def login_path(path):
66
+ # The path a server routes: refuse what normalises differently in different servers, then compare.
67
+ decoded = unquote(path)
68
+ require(not re.search(r'%2f|%5c', path, re.I) and '\\' not in decoded, 'encoded_separator')
69
+ segments = decoded.split('/')[1:]
70
+ require(not any(seg in ('.', '..') for seg in segments), 'dot_segment')
71
+ require(all(seg for seg in segments[:-1]), 'empty_segment')
72
+ return '/'.join([''] + [seg for seg in segments if seg]).lower() == '/fabric/v1/login'
73
+
74
+
18
75
  def safe_path(path):
19
76
  return (isinstance(path, str) and path.startswith('/') and not path.startswith('//')
20
77
  and len(path) <= 2048 and not re.search(r'[\\\x00-\x1f\x7f]', path))
@@ -42,10 +99,14 @@ def check(data):
42
99
  and not plain.username and not plain.password, 'invalid_http_fallback')
43
100
  http_page = plain.path + ('?' + plain.query if plain.query else '') + ('#' + plain.fragment if plain.fragment else '')
44
101
  require(safe_path(http_page) and http_page == (page or '/'), 'mismatched_page')
45
- sensitive = {'token', 'access_token', 'refresh_token', 'password', 'secret', 'login_code'}
46
- page_query = parse_qs(urlsplit(http_page).query)
47
- require(not sensitive.intersection(k.lower() for k in page_query), 'credential_query')
48
- require(urlsplit(http_page).path != '/fabric/v1/login', 'login_link_is_not_dashboard')
102
+ sensitive = {'token', 'access_token', 'refresh_token', 'password', 'secret', 'login_code', 'code', 'api_key',
103
+ 'apikey', 'key', 'auth'}
104
+ parts = urlsplit(http_page)
105
+ places = [parts.query, parts.fragment] + parts.fragment.split('?', 1)[1:]
106
+ require(not any(sensitive.intersection(k.lower() for k in parse_qs(p, keep_blank_values=True)) for p in places),
107
+ 'credential_query')
108
+ require(not any(carries_credential(p) for p in places), 'credential_query')
109
+ require(not login_path(parts.path), 'login_link_is_not_dashboard')
49
110
  state = host.get('state')
50
111
  require(state in ('available', 'not_installed', 'unknown', 'incompatible', 'handler_mismatch', 'unsupported'), 'unknown_host_state')
51
112
  target, viewer = data.get('target_device'), data.get('viewer_device')