feat: F5.2 sign-in with the dashboard's ticket and the service way

Server F checks the dashboard's entry ticket as itsdangerous does
(tools/ticket-oracle: tickets made by itsdangerous 2.2.0 itself), redeems
it at /__auth for a session cookie (only the key's SHA-256 is kept) and
decides at /sync by the session's grant: none, read or write; a frozen
project is read by everyone; every refusal names its reason (one list in
packages/protocol/src/sync.ts). A WebSocket from a page of another origin
is refused. /svc/* with the service key makes, deletes, freezes and
thaws projects, ends a person's grant or all their sessions (connections
let go) and lists the sessions. Snapshots, restoring and copying move to
F6 (they need the canonical doc.json and an epoch).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 13:56:45 +00:00
co-authored by Claude Opus 5.5
parent 88db6eff7e
commit 5ded84cb33
24 changed files with 1794 additions and 151 deletions
+95
View File
@@ -0,0 +1,95 @@
"""The dashboard's entry tickets, made by itsdangerous itself (plan §8.5).
The dashboard signs a ticket with itsdangerous' URLSafeTimedSerializer (the
same as Scratch's), server F checks it in Node. This writes tickets as the
dashboard makes them, with a fixed clock, and what itsdangerous answers when
it reads each one back at a given moment: the payload, or which error.
packages/server/test/tickets.python.json holds the result; the server's
tests read every ticket with the Node check and expect the same answer.
"""
import json
import sys
import itsdangerous
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from itsdangerous.timed import TimestampSigner
SECRET = "filius-test-secret"
SALT = "mdg-filius-ticket"
TTL = 60
SIGNED_AT = 1_790_000_000
clock = {"now": SIGNED_AT}
TimestampSigner.get_timestamp = lambda self: clock["now"]
PERSON = "0b8f5f2e-1a3c-4d5e-8f90-123456789abc"
PROJECT = "6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6"
def claims(**more):
base = {"u": PERSON, "n": "ab12", "d": "Ada Beispiel", "c": "#3366cc", "lang": "de", "p": PROJECT, "lvl": "write"}
base.update(more)
return base
def sign(payload, secret=SECRET, salt=SALT, at=SIGNED_AT):
clock["now"] = at
return URLSafeTimedSerializer(secret, salt=salt).dumps(payload)
def answer(ticket, now):
clock["now"] = now
try:
return {"payload": URLSafeTimedSerializer(SECRET, salt=SALT).loads(ticket, max_age=TTL)}
except SignatureExpired:
return {"error": "expired"}
except BadSignature:
return {"error": "badSignature"}
def tampered(ticket):
# One letter of the payload changed (the first after its first two).
letter = ticket[2]
return ticket[:2] + ("A" if letter != "A" else "B") + ticket[3:]
def main(out):
plain = sign(claims())
long_name = "Maximiliane Sophie Charlotte von und zu Beispielhausen-Musterstein"
cases = [
("plain", plain, SIGNED_AT),
("readLevel", sign(claims(lvl="read")), SIGNED_AT),
("umlautsAndEmoji", sign(claims(d="Jörg Ömer Weiß 🐧", lang="en")), SIGNED_AT),
("compressed", sign(claims(d=long_name, n=long_name.lower().replace(" ", "."))), SIGNED_AT),
("sixtySecondsOld", plain, SIGNED_AT + 60),
("sixtyOneSecondsOld", plain, SIGNED_AT + 61),
("fromTheFuture", plain, SIGNED_AT - 1),
("otherSecret", sign(claims(), secret="another-secret"), SIGNED_AT),
("scratchSalt", sign(claims(), salt="mdg-scratch-ticket"), SIGNED_AT),
("tamperedPayload", tampered(plain), SIGNED_AT),
("noSignature", plain.rsplit(".", 1)[0], SIGNED_AT),
("garbage", "not a ticket", SIGNED_AT),
("empty", "", SIGNED_AT),
("listPayload", sign(["u", "p"]), SIGNED_AT),
("withoutLevel", sign({k: v for k, v in claims().items() if k != "lvl"}), SIGNED_AT),
("unknownLevel", sign(claims(lvl="admin")), SIGNED_AT),
("projectNotAUuid", sign(claims(p="../etc")), SIGNED_AT),
("withoutName", sign({k: v for k, v in claims().items() if k not in ("n", "d")}), SIGNED_AT),
("onlyRequired", sign({"u": PERSON, "n": "ab12", "p": PROJECT, "lvl": "read"}), SIGNED_AT),
]
result = {
"itsdangerous": __import__("importlib.metadata").metadata.version("itsdangerous"),
"python": sys.version.split()[0],
"secret": SECRET,
"salt": SALT,
"ttl": TTL,
"signedAt": SIGNED_AT,
"cases": [{"name": name, "ticket": ticket, "now": now, **answer(ticket, now)} for name, ticket, now in cases],
}
with open(out, "w", encoding="utf-8") as file:
json.dump(result, file, ensure_ascii=False, indent=2)
file.write("\n")
if __name__ == "__main__":
main(sys.argv[1])