@passioncode-ai/passioncode 0.2.17 → 0.2.19
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.
- package/CHANGELOG.md +17 -0
- package/family.json +1 -1
- package/package.json +1 -1
- package/payload/.claude-plugin/marketplace.json +2 -2
- package/payload/manifest.json +8 -8
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +13 -13
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +56 -45
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/demo.html +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +3 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +264 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +12 -12
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +4 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +4 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +13 -11
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +11 -5
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/events-and-notifications.md +6 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +8 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +26 -26
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +3 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/profiles.md +10 -10
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +13 -13
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +4 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/settings-backups.md +3 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +5 -5
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +7 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -209
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +286 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +840 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -16
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -67
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +202 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +3 -13
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2521
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +400 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +402 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +78 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +902 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -38
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +16 -181
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -400
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +1 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +22 -44
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +31 -38
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3430
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +412 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1526 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +33 -33
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +27 -27
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +31 -31
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +9 -9
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +8 -8
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""The probe's rules that read one thing and need no running service (FAA-65): a plist's marker and priority, the
|
|
2
|
+
network guard's verdict, the board responder's fixture conversation, a session cookie's lifetime, a usage report, and
|
|
3
|
+
whether two repository URLs name one repository. ``check_service.py`` runs them against a live service.
|
|
4
|
+
Standard library only, Python 3.9+."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import email.utils
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
19
|
+
import fabric_service as fs # noqa: E402 - beside this file
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def plist_problems(plist: dict, label: object, token: object = None) -> list:
|
|
23
|
+
"""What makes a service's launchd job unfit to stay up: identity, restart, secrets."""
|
|
24
|
+
problems = []
|
|
25
|
+
if plist.get("Label") != label:
|
|
26
|
+
problems.append("Label %r" % plist.get("Label"))
|
|
27
|
+
if plist.get("RunAtLoad") is not True:
|
|
28
|
+
problems.append("RunAtLoad is not true")
|
|
29
|
+
if plist.get("KeepAlive") is not True:
|
|
30
|
+
problems.append("KeepAlive is %r, not true" % plist.get("KeepAlive"))
|
|
31
|
+
for key, value in (plist.get("EnvironmentVariables") or {}).items():
|
|
32
|
+
if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)$", key) and not key.endswith("_FILE"):
|
|
33
|
+
problems.append("secret-like variable %s in the plist" % key)
|
|
34
|
+
if token and token in str(value):
|
|
35
|
+
problems.append("the service token itself is in the plist")
|
|
36
|
+
return problems
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def marker_problem(plist: dict) -> Optional[str]:
|
|
40
|
+
"""What is wrong with the job's supervisor marker (DEC-0057; FAA-05), read the way the copy it starts reads it.
|
|
41
|
+
|
|
42
|
+
Without ``FABRIC_SERVICE_SUPERVISOR=launchd`` (or ``--fabric-supervisor launchd``) a copy that finds the
|
|
43
|
+
lock held exits 75 at once, and KeepAlive starts it again every ThrottleInterval."""
|
|
44
|
+
env = plist.get("EnvironmentVariables")
|
|
45
|
+
env = {str(k): str(v) for k, v in env.items()} if isinstance(env, dict) else {}
|
|
46
|
+
argv = plist.get("ProgramArguments")
|
|
47
|
+
argv = [str(a) for a in argv] if isinstance(argv, list) else []
|
|
48
|
+
marker = fs.supervisor_marker(env, argv)
|
|
49
|
+
if marker["kind"] == "supervised" and marker["manager"] == "launchd":
|
|
50
|
+
return None
|
|
51
|
+
fix = ("a copy that finds the lock held exits 75 at once and KeepAlive respawns it every ThrottleInterval; "
|
|
52
|
+
"regenerate the plist with launchd_plist / launchdPlist and reinstall")
|
|
53
|
+
if marker["kind"] == "conflict":
|
|
54
|
+
return "%s: %s" % (fs.marker_conflict_message(marker), fix)
|
|
55
|
+
if marker["kind"] == "supervised":
|
|
56
|
+
return "the marker names %s, not launchd: %s" % (marker["manager"], fix)
|
|
57
|
+
if marker.get("unreadable") is not None:
|
|
58
|
+
return "the marker %r names no supervisor: %s" % (marker["unreadable"], fix)
|
|
59
|
+
return "no %s=launchd: %s" % (fs.SUPERVISOR_ENV, fix)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def priority_problems(plist: dict) -> list:
|
|
63
|
+
"""A service that answers hosts and agents must not be scheduled as background work."""
|
|
64
|
+
out = []
|
|
65
|
+
if plist.get("ProcessType", "Standard") in ("Background", "Adaptive"):
|
|
66
|
+
out.append("ProcessType is %s" % plist.get("ProcessType"))
|
|
67
|
+
if isinstance(plist.get("Nice"), int) and plist["Nice"] > 0:
|
|
68
|
+
out.append("Nice is %d" % plist["Nice"])
|
|
69
|
+
if plist.get("LowPriorityIO") is True or plist.get("LowPriorityBackgroundIO") is True:
|
|
70
|
+
out.append("low-priority I/O")
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# A platform router (Heroku, Fly, Render, a CDN) routes by Host, so a request naming a foreign
|
|
75
|
+
# Host never reaches an online service: the router itself answers, typically 404 or 421. That is
|
|
76
|
+
# still a refusal — the service disclosed nothing — so for a remote placement the host check
|
|
77
|
+
# accepts it, as long as the body is not the well-known document. Every other guard request names
|
|
78
|
+
# the right Host, reaches the service, and must be the service's own 403.
|
|
79
|
+
PLATFORM_HOST_REFUSALS = (400, 404, 421)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def guard_verdict(rule: str, remote: bool, status: int, body: bytes) -> Tuple[str, str]:
|
|
83
|
+
if status == 403:
|
|
84
|
+
return "PASS", "HTTP 403"
|
|
85
|
+
disclosed = b"fabric-service/" in (body or b"")
|
|
86
|
+
if rule == "network.host-check" and remote and status in PLATFORM_HOST_REFUSALS and not disclosed:
|
|
87
|
+
return "PASS", "HTTP %d from the platform router: a foreign Host never reaches the service" % status
|
|
88
|
+
return "FAIL", "HTTP %d%s" % (status, ", and the body is the well-known document" if disclosed else "")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def board_responder_verdict(wk: Any, kit: Optional[str] = None) -> Tuple[str, str]:
|
|
92
|
+
"""board.responder: NOT_RUN when the well-known document declares no fabric-project-comms revision; FAIL when it
|
|
93
|
+
declares one this kit does not read or the kit's fixture conversation finds a problem; PASS otherwise. `kit` is the
|
|
94
|
+
service's own copy (a .py or .mjs path); by default the kit beside this probe."""
|
|
95
|
+
protocols = wk.get("protocols") if isinstance(wk, dict) else None
|
|
96
|
+
declared = [p for p in protocols if isinstance(p, str) and p.startswith("fabric-project-comms/")] if isinstance(protocols, list) else []
|
|
97
|
+
if not declared:
|
|
98
|
+
return "NOT_RUN", "the service declares no board participation (no fabric-project-comms revision in protocols)"
|
|
99
|
+
accepted = fs.accepted_revisions("fabric-project-comms")
|
|
100
|
+
unread = [p for p in declared if "error" in fs.negotiate_revision(p, accepted)]
|
|
101
|
+
if unread:
|
|
102
|
+
return "FAIL", "declares %s, which this kit does not read (it reads %s)" % (", ".join(unread), ", ".join(accepted))
|
|
103
|
+
try:
|
|
104
|
+
if kit and kit.endswith(".mjs"):
|
|
105
|
+
if not shutil.which("node"):
|
|
106
|
+
return "NOT_RUN", "node is not installed to run %s's fixture conversation" % kit
|
|
107
|
+
url = Path(kit).resolve().as_uri()
|
|
108
|
+
out = subprocess.run(["node", "--input-type=module", "-e",
|
|
109
|
+
"import(process.env.FABRIC_BOARD_KIT).then(async (m) => console.log(JSON.stringify(await m.selfCheck())))"],
|
|
110
|
+
env=dict(os.environ, FABRIC_BOARD_KIT=url), capture_output=True, text=True, timeout=60)
|
|
111
|
+
problems = json.loads(out.stdout.strip().splitlines()[-1]) if out.returncode == 0 and out.stdout.strip() else ["the Node kit did not run: %s" % out.stderr.strip()[:200]]
|
|
112
|
+
else:
|
|
113
|
+
# A clean interpreter: the kit is several modules (FAA-65), and modules this probe already imported from
|
|
114
|
+
# beside it would stand in for the copy under test.
|
|
115
|
+
path = Path(kit) if kit else Path(__file__).resolve().parent / "fabric_board.py"
|
|
116
|
+
code = ("import json, sys; sys.path.insert(0, sys.argv[1]); import %s as board; "
|
|
117
|
+
"print(json.dumps(board.self_check()))" % path.stem)
|
|
118
|
+
out = subprocess.run([sys.executable, "-E", "-s", "-c", code, str(path.resolve().parent)], capture_output=True,
|
|
119
|
+
text=True, timeout=60, cwd=str(path.resolve().parent))
|
|
120
|
+
problems = json.loads(out.stdout.strip().splitlines()[-1]) if out.returncode == 0 and out.stdout.strip() else ["the kit did not run: %s" % out.stderr.strip()[-200:]]
|
|
121
|
+
except Exception as exc: # noqa: BLE001 - a kit that cannot run its conversation fails the rule
|
|
122
|
+
return "FAIL", "the kit's fixture conversation did not run: %s" % exc.__class__.__name__
|
|
123
|
+
if problems:
|
|
124
|
+
return "FAIL", "; ".join(problems)
|
|
125
|
+
return "PASS", "declares %s; the kit%s answers the fixture conversation (whole, fenced, no epoch)" % (
|
|
126
|
+
", ".join(declared), " at %s" % kit if kit else " beside this probe")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def session_lifetime_problem(set_cookie: str, now: Optional[float] = None) -> Optional[str]:
|
|
130
|
+
"""DEC-0039: a session cookie set at sign-in lives at most 7 days. Max-Age wins over Expires (RFC 6265
|
|
131
|
+
§5.3); a cookie with neither ends with the browser session. None when it is within the bound."""
|
|
132
|
+
attrs = {}
|
|
133
|
+
for part in set_cookie.split(";")[1:]:
|
|
134
|
+
name, _, value = part.strip().partition("=")
|
|
135
|
+
attrs[name.strip().lower()] = value.strip()
|
|
136
|
+
bound = fs.OPERATOR_SESSION_MAX_SECONDS
|
|
137
|
+
if "max-age" in attrs:
|
|
138
|
+
if not re.fullmatch(r"-?\d+", attrs["max-age"]):
|
|
139
|
+
return "the session cookie's Max-Age %r is not a number" % attrs["max-age"]
|
|
140
|
+
if int(attrs["max-age"]) > bound:
|
|
141
|
+
return "the session cookie's Max-Age %s outlives 7 days (%d s, DEC-0039)" % (attrs["max-age"], bound)
|
|
142
|
+
return None
|
|
143
|
+
if "expires" in attrs:
|
|
144
|
+
try:
|
|
145
|
+
at = email.utils.parsedate_to_datetime(attrs["expires"]).timestamp()
|
|
146
|
+
except (TypeError, ValueError, IndexError, OverflowError):
|
|
147
|
+
return "the session cookie's Expires %r is not a date" % attrs["expires"]
|
|
148
|
+
if at - (time.time() if now is None else now) > bound:
|
|
149
|
+
return "the session cookie's Expires %s outlives 7 days (DEC-0039)" % attrs["expires"]
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _semver(text: str) -> Tuple[int, int, int]:
|
|
154
|
+
m = re.match(r"^(\d+)\.(\d+)\.(\d+)", text or "")
|
|
155
|
+
return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else (0, 0, 0)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def usage_problems(report: Any, service_id: str, instance: str) -> List[str]:
|
|
159
|
+
"""What makes this not a DEC-0021 usage report of the service: shape, identity and the sums of
|
|
160
|
+
FAC-SEM-025. An unknown cost must be null; a day's totals are the sums of its models."""
|
|
161
|
+
if not isinstance(report, dict):
|
|
162
|
+
return ["not an object"]
|
|
163
|
+
out: List[str] = []
|
|
164
|
+
if report.get("protocol") != fs.PROTOCOL:
|
|
165
|
+
out.append("protocol is %r" % report.get("protocol"))
|
|
166
|
+
if report.get("service") != {"id": service_id, "instance": instance}:
|
|
167
|
+
out.append("reports for %r, not %s.%s" % (report.get("service"), service_id, instance))
|
|
168
|
+
if report.get("currency") != "USD":
|
|
169
|
+
out.append("currency is not USD")
|
|
170
|
+
days = report.get("days")
|
|
171
|
+
if not isinstance(days, list) or len(days) > fs.USAGE_DAYS:
|
|
172
|
+
return out + ["days is not a list of at most 31"]
|
|
173
|
+
# The arithmetic of FAC-SEM-025 is the kit's `usage_findings`, the contract's `usageRules` held to its vectors: one
|
|
174
|
+
# implementation, so the probe never disagrees with the kit and never throws on a count that is not a number.
|
|
175
|
+
for i, day in enumerate(days):
|
|
176
|
+
if not isinstance(day, dict):
|
|
177
|
+
out.append("days[%d]: not an object" % i)
|
|
178
|
+
continue
|
|
179
|
+
if not isinstance(day.get("byModel"), list):
|
|
180
|
+
out.append("days[%d]: byModel missing" % i)
|
|
181
|
+
continue
|
|
182
|
+
for row, at in [(day, "days[%d]" % i)] + [(m, "days[%d].byModel[%d]" % (i, j)) for j, m in enumerate(day["byModel"]) if isinstance(m, dict)]:
|
|
183
|
+
cost = row.get("costUsd")
|
|
184
|
+
if cost is not None and (isinstance(cost, bool) or not isinstance(cost, (int, float)) or cost < 0):
|
|
185
|
+
out.append("%s: costUsd is not a non-negative number" % at)
|
|
186
|
+
for j, m in enumerate(day["byModel"]):
|
|
187
|
+
# DEC-0046, DEC-0061: a row's route is a login in its vendor's runner, an issued key, or the platform's
|
|
188
|
+
# own metered models (a listing in the cloud).
|
|
189
|
+
if isinstance(m, dict) and "route" in m and m["route"] not in fs.USAGE_ROUTES:
|
|
190
|
+
out.append("days[%d].byModel[%d]: route %r is not subscription, key or platform-metered" % (i, j, m["route"]))
|
|
191
|
+
for finding in fs.usage_findings(report):
|
|
192
|
+
out.append("%s: %s" % (finding["instancePath"] or "/", finding["message"]))
|
|
193
|
+
return out
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def same_repository(a: str, b: str) -> bool:
|
|
197
|
+
"""git@github.com:o/r.git, https://github.com/o/r and github.com/o/r are one repository."""
|
|
198
|
+
def norm(u: str) -> str:
|
|
199
|
+
u = u.strip().lower()
|
|
200
|
+
u = re.sub(r"^git@([^:]+):", r"\1/", u)
|
|
201
|
+
u = re.sub(r"^[a-z+]+://", "", u)
|
|
202
|
+
u = re.sub(r"^[^@/]+@", "", u)
|
|
203
|
+
return re.sub(r"\.git$", "", u).rstrip("/")
|
|
204
|
+
return bool(a and b) and norm(a) == norm(b)
|
|
@@ -408,7 +408,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
408
408
|
for c in report["copies"]:
|
|
409
409
|
print(" %-8s %s (%s)" % (c["state"], c["path"], c["version"] or "no version marker"))
|
|
410
410
|
if not report["copies"]:
|
|
411
|
-
print(" no kit copy" + ("
|
|
411
|
+
print(" no kit copy" + (" (services %s declare a dashboard)" % ", ".join(report["services"])
|
|
412
412
|
if report["dashboard"] and report["services"] else ""))
|
|
413
413
|
if report["services"]:
|
|
414
414
|
print(" services from this repository: %s" % ", ".join(report["services"]))
|
package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// Who may call a service: the descriptor token, the network guard, the operator's one-time login and session, remote placement (DEC-0019), the feed client's token header.
|
|
2
|
+
// Part of the Node service kit (FAA-65, layer L3); the twin of fabric_auth.py. Node 20+, no dependency.
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { PROTOCOL, ServiceError, nowIso } from './fabric-base.mjs';
|
|
7
|
+
import { tokenMatches, windowsTokenProblem } from './fabric-platform.mjs';
|
|
8
|
+
import { atomicWrite, ensurePrivateDir, servicesDir } from './fabric-paths.mjs';
|
|
9
|
+
import { isPlainObject } from './fabric-usage.mjs';
|
|
10
|
+
import { validateDescriptor, writeDescriptor } from './fabric-descriptor.mjs';
|
|
11
|
+
|
|
12
|
+
export const SESSION_COOKIE = 'fabric_session';
|
|
13
|
+
|
|
14
|
+
// DEC-0019: a remote placement's cookie is host-bound and HTTPS-only.
|
|
15
|
+
export const REMOTE_SESSION_COOKIE = '__Host-fabric_session';
|
|
16
|
+
|
|
17
|
+
const CODE = /^[A-Za-z0-9_-]{16,256}$/;
|
|
18
|
+
|
|
19
|
+
export function ensureToken(file) {
|
|
20
|
+
if (fs.existsSync(file)) return readToken(file);
|
|
21
|
+
ensurePrivateDir(path.dirname(file));
|
|
22
|
+
const token = crypto.randomBytes(32).toString('base64url');
|
|
23
|
+
atomicWrite(file, token, 0o600);
|
|
24
|
+
return token;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Refuse a symlink, a file owned by someone else, or one others can read. On Windows the rule is
|
|
29
|
+
* service.md "Windows token files" (DEC-0032, DEC-0033, DEC-0057): windowsTokenAclProblem over the
|
|
30
|
+
* file's flags, owner and ACEs; a refusal names the SID, never the contents.
|
|
31
|
+
*/
|
|
32
|
+
export function readToken(file) {
|
|
33
|
+
const info = fs.lstatSync(file);
|
|
34
|
+
if (info.isSymbolicLink()) throw new ServiceError(`Token file ${file} is a symlink; refusing it.`);
|
|
35
|
+
if (process.platform === 'win32') {
|
|
36
|
+
const problem = windowsTokenProblem(file, info);
|
|
37
|
+
if (problem) throw new ServiceError(problem);
|
|
38
|
+
} else {
|
|
39
|
+
if (typeof process.getuid === 'function' && info.uid !== process.getuid()) throw new ServiceError(`Token file ${file} belongs to another user.`);
|
|
40
|
+
if (info.mode & 0o077) throw new ServiceError(`Token file ${file} is readable by others; set mode 0600.`);
|
|
41
|
+
}
|
|
42
|
+
const token = fs.readFileSync(file, 'utf8').trim();
|
|
43
|
+
if (token.length < 16) throw new ServiceError(`Token file ${file} holds no usable token.`);
|
|
44
|
+
return token;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The one header a client sends the service token in, as the descriptor's `auth` declares it: `header` defaults
|
|
49
|
+
* to Authorization and `scheme` to Bearer; `none` carries the raw token. Never assume `Authorization: Bearer`.
|
|
50
|
+
*/
|
|
51
|
+
export function tokenHeader(auth, token) {
|
|
52
|
+
const declared = isPlainObject(auth) ? auth : {};
|
|
53
|
+
const name = typeof declared.header === 'string' ? declared.header : 'Authorization';
|
|
54
|
+
const scheme = typeof declared.scheme === 'string' ? declared.scheme : 'Bearer';
|
|
55
|
+
return { name, value: scheme === 'none' ? token : `Bearer ${token}` };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** DEC-0039 (service.md "The session's lifetime"): the longest a service may keep an operator session. */
|
|
59
|
+
export const OPERATOR_SESSION_IDLE_SECONDS = 12 * 60 * 60;
|
|
60
|
+
|
|
61
|
+
export const OPERATOR_SESSION_MAX_SECONDS = 7 * 24 * 60 * 60;
|
|
62
|
+
|
|
63
|
+
// Compare bytes, not UTF-16 units: a non-ASCII value of the right length must be a refusal, not a RangeError.
|
|
64
|
+
const safeEqual = (a, b) => {
|
|
65
|
+
const x = Buffer.from(String(a)), y = Buffer.from(String(b));
|
|
66
|
+
return x.length === y.length && crypto.timingSafeEqual(x, y);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const sessionBound = (value, longest) => {
|
|
70
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) throw new ServiceError('a session bound must be a finite number of seconds.');
|
|
71
|
+
return Math.min(Math.trunc(value), longest);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Single-use login codes, and operator sessions with the DEC-0039 lifetime: a session ends 12 h after its
|
|
75
|
+
// last use, 7 days after the code was redeemed, on logout, and when the descriptor token rotates. The server
|
|
76
|
+
// enforces all four: the cookie carries a signed claim (session id, sign-in time, token revision) and the
|
|
77
|
+
// code store keeps a record per session (last use, logout). Rotating the key with revokeAll() ends every one.
|
|
78
|
+
export class LoginCodes {
|
|
79
|
+
// `stateDir` — a local service keeps codes, sessions and the session key in files. An online service
|
|
80
|
+
// (DEC-0019) usually has no durable disk: pass `stateDir = null` with `{ store, key }` — `store` like
|
|
81
|
+
// `new MemoryCodeStore()` and `key` a Buffer from a platform secret. A memory store forgets codes AND
|
|
82
|
+
// sessions on a restart, so a deploy signs the operator out (the host signs in again on the page's 401);
|
|
83
|
+
// pass a durable store to keep sessions. `token` is the descriptor token, or a function returning the
|
|
84
|
+
// current one: a session begun under one token is refused once it rotates. A service MAY shorten
|
|
85
|
+
// `idleSeconds` or `maxSeconds`, never lengthen them.
|
|
86
|
+
constructor(stateDir, ttlSeconds = 120, { store = null, key = null, token = null,
|
|
87
|
+
idleSeconds = OPERATOR_SESSION_IDLE_SECONDS, maxSeconds = OPERATOR_SESSION_MAX_SECONDS, clock = () => Date.now() / 1000 } = {}) {
|
|
88
|
+
this.ttl = Math.min(ttlSeconds, 120);
|
|
89
|
+
this.idleSeconds = sessionBound(idleSeconds, OPERATOR_SESSION_IDLE_SECONDS);
|
|
90
|
+
this.maxSeconds = sessionBound(maxSeconds, OPERATOR_SESSION_MAX_SECONDS);
|
|
91
|
+
this.store = store;
|
|
92
|
+
this.fixedKey = key;
|
|
93
|
+
this.token = token;
|
|
94
|
+
this.clock = clock;
|
|
95
|
+
if (stateDir) {
|
|
96
|
+
this.dir = ensurePrivateDir(stateDir);
|
|
97
|
+
this.keyPath = path.join(this.dir, 'session.key');
|
|
98
|
+
this.codesPath = path.join(this.dir, 'login-codes.json');
|
|
99
|
+
} else if (!store || !key) {
|
|
100
|
+
throw new ServiceError('LoginCodes without a state directory needs { store, key }.');
|
|
101
|
+
}
|
|
102
|
+
if (key && Buffer.from(key).length < 32) throw new ServiceError('the session key must be at least 32 bytes.');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
key() {
|
|
106
|
+
if (this.fixedKey) return Buffer.from(this.fixedKey);
|
|
107
|
+
if (!fs.existsSync(this.keyPath)) atomicWrite(this.keyPath, crypto.randomBytes(32), 0o600);
|
|
108
|
+
return fs.readFileSync(this.keyPath);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
load() {
|
|
112
|
+
if (this.store) return this.store.load();
|
|
113
|
+
try { return JSON.parse(fs.readFileSync(this.codesPath, 'utf8')); } catch { return {}; }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
save(codes) {
|
|
117
|
+
const horizon = this.clock() - 3600;
|
|
118
|
+
const kept = Object.fromEntries(Object.entries(codes).filter(([, v]) => (v.expires ?? 0) > horizon));
|
|
119
|
+
if (this.store) { this.store.save(kept); return; }
|
|
120
|
+
atomicWrite(this.codesPath, JSON.stringify(kept), 0o600);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
issue() {
|
|
124
|
+
const code = crypto.randomBytes(24).toString('base64url');
|
|
125
|
+
const expires = this.clock() + this.ttl;
|
|
126
|
+
const codes = this.load();
|
|
127
|
+
codes[crypto.createHash('sha256').update(code).digest('hex')] = { expires, used: false };
|
|
128
|
+
this.save(codes);
|
|
129
|
+
return { url: `/fabric/v1/login?code=${code}`, expiresAt: new Date(expires * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z') };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The code is recorded as used BEFORE it is honoured, and burned even when expired.
|
|
133
|
+
redeem(code) {
|
|
134
|
+
if (!code || !CODE.test(code)) return null;
|
|
135
|
+
const digest = crypto.createHash('sha256').update(code).digest('hex');
|
|
136
|
+
const codes = this.load();
|
|
137
|
+
const entry = codes[digest];
|
|
138
|
+
if (!entry || entry.used) return null;
|
|
139
|
+
entry.used = true;
|
|
140
|
+
this.save(codes);
|
|
141
|
+
const now = this.clock();
|
|
142
|
+
if (entry.expires < now) return null;
|
|
143
|
+
const session = crypto.randomBytes(18).toString('base64url');
|
|
144
|
+
const signedInAt = Math.trunc(now);
|
|
145
|
+
codes[LoginCodes.recordKey(session)] = { expires: signedInAt + this.maxSeconds, signedInAt, lastUsedAt: now };
|
|
146
|
+
this.save(codes);
|
|
147
|
+
const claim = `v2.${session}.${signedInAt}.${this.tokenRevision()}`;
|
|
148
|
+
return `${claim}.${this.mac(claim)}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** True while the session is live; a use within the bounds refreshes the idle bound. */
|
|
152
|
+
sessionValid(value) {
|
|
153
|
+
const parsed = this.parse(value);
|
|
154
|
+
if (!parsed) return false;
|
|
155
|
+
const [session, signedInAt] = parsed;
|
|
156
|
+
const now = this.clock();
|
|
157
|
+
if (now >= signedInAt + this.maxSeconds) return false;
|
|
158
|
+
const codes = this.load();
|
|
159
|
+
const record = codes[LoginCodes.recordKey(session)];
|
|
160
|
+
if (!record || record.signedInAt !== signedInAt || record.loggedOutAt != null) return false;
|
|
161
|
+
const last = Number(record.lastUsedAt ?? 0);
|
|
162
|
+
if (now >= last + this.idleSeconds) return false;
|
|
163
|
+
if (now - last >= Math.min(60, Math.max(1, Math.trunc(this.idleSeconds / 10)))) {
|
|
164
|
+
record.lastUsedAt = now;
|
|
165
|
+
this.save(codes);
|
|
166
|
+
}
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** End this session on the server; clear the browser's copy with expiredSessionCookieHeader(). */
|
|
171
|
+
logout(value) {
|
|
172
|
+
const parsed = this.parse(value);
|
|
173
|
+
if (!parsed) return false;
|
|
174
|
+
const codes = this.load();
|
|
175
|
+
const record = codes[LoginCodes.recordKey(parsed[0])];
|
|
176
|
+
if (!record || record.signedInAt !== parsed[1] || record.loggedOutAt != null) return false;
|
|
177
|
+
record.loggedOutAt = this.clock();
|
|
178
|
+
this.save(codes);
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
parse(value) {
|
|
183
|
+
if (typeof value !== 'string' || !/^[\x21-\x7e]*$/.test(value)) return null;
|
|
184
|
+
const parts = value.split('.');
|
|
185
|
+
if (parts.length !== 5 || parts[0] !== 'v2' || !parts[1] || !/^\d+$/.test(parts[2])) return null;
|
|
186
|
+
if (!safeEqual(parts[4], this.mac(parts.slice(0, 4).join('.')))) return null;
|
|
187
|
+
if (!safeEqual(parts[3], this.tokenRevision())) return null;
|
|
188
|
+
return [parts[1], Number(parts[2])];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
mac(claim) { return crypto.createHmac('sha256', this.key()).update(claim).digest('hex'); }
|
|
192
|
+
|
|
193
|
+
tokenRevision() {
|
|
194
|
+
const token = typeof this.token === 'function' ? this.token() : this.token;
|
|
195
|
+
if (!token) return '-';
|
|
196
|
+
return crypto.createHmac('sha256', this.key()).update(`token-revision\0${token}`).digest('hex').slice(0, 16);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
static recordKey(session) { return `s:${crypto.createHash('sha256').update(session).digest('hex')}`; }
|
|
200
|
+
|
|
201
|
+
revokeAll() {
|
|
202
|
+
if (this.fixedKey) throw new ServiceError('a platform-held session key is rotated on the platform, not here.');
|
|
203
|
+
atomicWrite(this.keyPath, crypto.randomBytes(32), 0o600);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** DEC-0019: a code store held in memory — for an online service with no durable disk. */
|
|
208
|
+
export class MemoryCodeStore {
|
|
209
|
+
constructor() { this.codes = {}; }
|
|
210
|
+
load() { return JSON.parse(JSON.stringify(this.codes)); }
|
|
211
|
+
save(codes) { this.codes = JSON.parse(JSON.stringify(codes)); }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The cookie lives no longer than the absolute bound (DEC-0039); the server ends it sooner. */
|
|
215
|
+
export const sessionCookieHeader = (value, maxAge = OPERATOR_SESSION_MAX_SECONDS) => `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}`;
|
|
216
|
+
|
|
217
|
+
/** DEC-0019: the remote cookie — `__Host-` name, Secure, no Domain, Path=/. */
|
|
218
|
+
export const remoteSessionCookieHeader = (value, maxAge = OPERATOR_SESSION_MAX_SECONDS) => `${REMOTE_SESSION_COOKIE}=${value}; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=${maxAge}`;
|
|
219
|
+
|
|
220
|
+
/** The header that clears the browser's session cookie, sent with a logout. */
|
|
221
|
+
export const expiredSessionCookieHeader = (remote = false) => (remote ? remoteSessionCookieHeader : sessionCookieHeader)('', 0);
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* DEC-0019: the request guard of an online service. `origin` is the service's own origin
|
|
225
|
+
* (`https://agent.example.com`). `forwardedProto` is the platform-set scheme where TLS ends
|
|
226
|
+
* before the process (`x-forwarded-proto`); pass `undefined` when the process terminates TLS.
|
|
227
|
+
* Returns the refusal sentence, or null.
|
|
228
|
+
*/
|
|
229
|
+
export function checkRemoteRequest(origin, host, requestOrigin, secFetchSite, forwardedProto) {
|
|
230
|
+
const own = new URL(origin);
|
|
231
|
+
if (String(host ?? '').toLowerCase() !== own.host) return `Host ${host} is not this service.`;
|
|
232
|
+
if (forwardedProto !== undefined && forwardedProto !== null && String(forwardedProto).split(',')[0].trim() !== 'https') return 'This service answers over https only.';
|
|
233
|
+
if (requestOrigin !== undefined && requestOrigin !== null && requestOrigin !== own.origin) return `Origin ${requestOrigin} is not this service.`;
|
|
234
|
+
if (secFetchSite === 'cross-site') return 'Cross-site requests are refused.';
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* DEC-0019: may this request read the well-known document? A local service answers anyone (the
|
|
240
|
+
* loopback guard already ran); a remote one only the bearer of the service token. A `false`
|
|
241
|
+
* answer is a `401` with an EMPTY body — nothing about the service is disclosed.
|
|
242
|
+
*/
|
|
243
|
+
export function wellKnownAllowed(placement, authorization, token, scheme = 'Bearer') {
|
|
244
|
+
return placement !== 'remote' || tokenMatches(authorization, token, scheme);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The token directory a host-side installer uses for remote services on this computer. */
|
|
248
|
+
export function remoteTokenFile(id, instance = 'default', dir = servicesDir()) {
|
|
249
|
+
return path.join(path.dirname(dir), 'tokens', `${id}.${instance}.token`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* DEC-0019, installer side: register an online service on THIS computer. Writes the token file
|
|
254
|
+
* (0600, never printed) and the descriptor; the same token must be set on the hosting platform as
|
|
255
|
+
* a secret. Returns the descriptor path. `token` is read from the caller — a file or stdin —
|
|
256
|
+
* never from an argument vector.
|
|
257
|
+
*/
|
|
258
|
+
/**
|
|
259
|
+
* DEC-0019, installer side: register an online service on THIS computer — the token file (0600, never printed) and the
|
|
260
|
+
* descriptor. DEC-0061: `installedBy` defaults to `{by: 'person', role: 'operator', tool: 'fabric-service register-remote'}`
|
|
261
|
+
* (a person runs this); `runner` (`{kind?, credentialKind}`) says how its model calls sign in, as a kind and never a
|
|
262
|
+
* credential; `peer` (`{endpoint, custody, residency, maxDataClass}`) offers it to others and needs `runner`.
|
|
263
|
+
*/
|
|
264
|
+
export function registerRemote({ id, instance = 'default', name, summary, origin, token, doctor, dir = servicesDir(),
|
|
265
|
+
installedBy = { by: 'person', role: 'operator', tool: 'fabric-service register-remote' }, runner, peer }) {
|
|
266
|
+
if (!token || String(token).trim().length < 16) throw new ServiceError('the service token must be at least 16 characters.');
|
|
267
|
+
const tokenFile = remoteTokenFile(id, instance, dir);
|
|
268
|
+
const d = {
|
|
269
|
+
protocol: PROTOCOL, id, instance, name, ...(summary ? { summary } : {}), placement: 'remote', origin,
|
|
270
|
+
auth: { tokenFile }, lifecycle: { manager: 'none' }, ...(doctor ? { commands: { doctor } } : {}),
|
|
271
|
+
installedAt: nowIso(), installedBy, ...(runner ? { runner: { ...runner } } : {}), ...(peer ? { peer: { ...peer } } : {}),
|
|
272
|
+
};
|
|
273
|
+
const problems = validateDescriptor(d);
|
|
274
|
+
if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
|
|
275
|
+
ensurePrivateDir(path.dirname(tokenFile));
|
|
276
|
+
atomicWrite(tokenFile, String(token).trim(), 0o600);
|
|
277
|
+
return writeDescriptor(d, dir);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function cookieValue(header, name = SESSION_COOKIE) {
|
|
281
|
+
for (const part of String(header ?? '').split(';')) {
|
|
282
|
+
const [k, ...rest] = part.trim().split('=');
|
|
283
|
+
if (k === name) return rest.join('=');
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
}
|