From 062826e1268fd20fbb8a5afe82f0f9127044c295 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Thu, 2 Jul 2026 23:18:23 +0200 Subject: [PATCH] feat: add RememberMe blueprint illustration First capability blueprint in the gallery. Transcribed from the ontology remember-me README + .als: the persistence lifecycle (NotPersisted -> Persisting -> Persisted -> Revoked), its 8 functions, and a specimen annotating the opt-in sign-in and cold-start revive screens plus the secure-storage vault it owns. --- src/data/blueprints.ts | 3 + src/data/rememberMeBlueprint.ts | 305 ++++++++++++++++++ src/specimens/RememberMeSpecimen.vue | 466 +++++++++++++++++++++++++++ 3 files changed, 774 insertions(+) create mode 100644 src/data/rememberMeBlueprint.ts create mode 100644 src/specimens/RememberMeSpecimen.vue diff --git a/src/data/blueprints.ts b/src/data/blueprints.ts index b4a8e17..014add3 100644 --- a/src/data/blueprints.ts +++ b/src/data/blueprints.ts @@ -7,9 +7,11 @@ import type { Blueprint } from "./blueprint" import { list } from "./listBlueprint" import { grid } from "./gridBlueprint" import { calendar } from "./calendarBlueprint" +import { rememberMe } from "./rememberMeBlueprint" import ListSpecimen from "@/specimens/ListSpecimen.vue" import GridSpecimen from "@/specimens/GridSpecimen.vue" import CalendarSpecimen from "@/specimens/CalendarSpecimen.vue" +import RememberMeSpecimen from "@/specimens/RememberMeSpecimen.vue" export interface RegistryEntry { blueprint: Blueprint @@ -20,6 +22,7 @@ export const REGISTRY: Record = { list: { blueprint: list, specimen: ListSpecimen }, grid: { blueprint: grid, specimen: GridSpecimen }, calendar: { blueprint: calendar, specimen: CalendarSpecimen }, + "remember-me": { blueprint: rememberMe, specimen: RememberMeSpecimen }, } export const BLUEPRINTS: Blueprint[] = Object.values(REGISTRY).map((e) => e.blueprint) diff --git a/src/data/rememberMeBlueprint.ts b/src/data/rememberMeBlueprint.ts new file mode 100644 index 0000000..666b66d --- /dev/null +++ b/src/data/rememberMeBlueprint.ts @@ -0,0 +1,305 @@ +// The RememberMe blueprint, as data. Transcribed by hand from the ontology +// `remember-me` README + remember-me.als. Unlike List/Grid/Calendar (visual +// *feature* layouts), RememberMe is a *capability* that composes onto a +// PasswordAuth host: a consent-gated persistent token that survives browser +// close and revives the host into `Refreshing` — never directly `Authenticated`. +// It owns its own lifecycle (NotPersisted → Persisting → Persisted → Revoked), +// so it defines its own state machine rather than sharing LOADSTATE_MACHINE. + +import type { Blueprint, StateMachine } from "./blueprint" + +// NotPersisted (initial) → Persisting → Persisted, with the three "return to +// NotPersisted" edges (storeFailed / expired / clear) arcing below the row and +// revoke() branching Persisted → Revoked. Node coordinates are hand-placed. +const REMEMBER_ME_MACHINE: StateMachine = { + initId: "notpersisted", + caption: "Persistence lifecycle", + field: "state : RememberMeState", + nodes: [ + { id: "notpersisted", x: 40, y: 150, w: 150, h: 44, label: "NotPersisted" }, + { id: "persisting", x: 300, y: 150, w: 120, h: 44, label: "Persisting" }, + { id: "persisted", x: 560, y: 150, w: 120, h: 44, label: "Persisted" }, + { id: "revoked", x: 760, y: 150, w: 120, h: 44, label: "Revoked" }, + ], + edges: [ + // forward path (arcs up, labels above the row) + { from: "notpersisted", to: "persisting", label: "startPersist()", kind: "persist", off: -90 }, + { from: "persisting", to: "persisted", label: "tokenStored(t)", kind: "store", off: -90 }, + { from: "persisted", to: "revoked", label: "revoke()", kind: "revoke", off: -90 }, + // returns to NotPersisted (arc below the row; deeper = wider span) + { from: "persisting", to: "notpersisted", label: "storeFailed()", kind: "storeFail", off: -70 }, + { from: "persisted", to: "notpersisted", label: "expired()", kind: "expire", off: -120 }, + { from: "revoked", to: "notpersisted", label: "clear()", kind: "clear", off: -172 }, + ], +} + +export const rememberMe: Blueprint = { + slug: "remember-me", + name: "RememberMe", + tagline: + "A consent-gated persistent token that survives browser close and revives a signed-out host into Refreshing — never directly into Authenticated.", + role: "capability", + extendsName: "PasswordAuth", + + sig: { + header: "RememberMeBinding ◁ PasswordAuth", + fields: [ + { name: "host", type: "one PasswordAuth", note: "the host session being persisted" }, + { name: "state", type: "RememberMeState", note: "persistence lifecycle position" }, + { name: "optIn", type: "one Bool", note: "captured at sign-in; gates persistence" }, + { name: "token", type: "lone PersistentToken", note: "present iff state = Persisted" }, + ], + types: [ + "RememberMeState = NotPersisted | Persisting | Persisted | Revoked", + "PersistentToken { subject : Subject, issuedAt, expiresAt : Time } -- absolute TTL", + "token ≠ ⊥ ⟺ state = Persisted", + "state ∈ {Persisting, Persisted} ⟹ optIn = True (consent-gated)", + "token.subject = host.identity.subject at the moment of tokenStored", + ], + }, + + functions: [ + { + id: "capture", + name: "Capture", + fig: "01", + caps: ["RememberMe"], + verb: "Read the user's opt-in choice at sign-in (checkbox / toggle) and record optIn.", + state: [["optIn", "one Bool"]], + behaviors: [], + invariants: ["optIn defaults to False — persistence is opt-in, never opt-out"], + failures: [], + perf: [], + notes: ["optIn is captured once at sign-in; it is not re-prompted on every launch."], + }, + { + id: "trigger", + name: "Trigger", + fig: "02", + caps: ["RememberMe", "PasswordAuth"], + verb: "Observe host.state transitions; fire startPersist() only when the host reaches Authenticated with opt-in.", + state: [ + ["state", "RememberMeState"], + ["host.state", "AuthState"], + ], + behaviors: [ + { + sig: "startPersist()", + pre: "host.state = Authenticated, optIn = True, state = NotPersisted", + eff: "state ← Persisting", + }, + ], + invariants: ["state ∈ {Persisting, Persisted} ⟹ optIn = True — persistence is consent-gated"], + failures: [ + "Lost opt-in — startPersist() fires without optIn = True; the user gets surprise persistence when they wanted a one-shot session", + ], + perf: [], + notes: [ + "startPersist() fires on the host's transition into Authenticated — not on Idle, not on Refreshing.", + ], + }, + { + id: "issue", + name: "Issue", + fig: "03", + caps: ["PersistentToken", "PasswordAuth"], + verb: "Backend mints a long-lived, subject-bound PersistentToken with an absolute expiresAt.", + state: [["token", "lone PersistentToken"]], + behaviors: [], + invariants: [ + "token.subject = host.identity.subject at issuance — no cross-account binding", + "expiresAt is absolute — set once at issuance, never extended by use", + ], + failures: [ + "Cross-account binding — token.subject differs from the verified host identity; the token later impersonates a different user on revival", + "Sliding expiry — expiresAt deferred whenever the token is used; the TTL is defeated and the token effectively never expires", + ], + perf: [ + "Token issuance — Persisting → Persisted completes within 200 ms after the host reaches Authenticated", + "Token TTL — persistent tokens expire within 30 days of issuance to bound the theft window", + ], + notes: [], + }, + { + id: "store", + name: "Store", + fig: "04", + caps: ["SecureStorage", "PersistentToken"], + verb: "Persist the token bytes to platform-secure storage; surface storeFailed() if storage is unavailable.", + state: [ + ["state", "RememberMeState"], + ["token", "lone PersistentToken"], + ], + behaviors: [ + { + sig: "tokenStored(t)", + pre: "state = Persisting, t.subject = host.identity.subject", + eff: "state ← Persisted, token ← t", + }, + { + sig: "storeFailed()", + pre: "state = Persisting, secure storage unavailable", + eff: "state ← NotPersisted, token ← ⊥", + }, + ], + invariants: [ + "token ≠ ⊥ ⟺ state = Persisted — bytes exist only after tokenStored; Persisting is the in-flight window", + "the token lives in platform-secure storage (Keychain, Credential Manager, HttpOnly cookie) — never in plain localStorage", + ], + failures: [ + "Insecure storage — token kept in localStorage / plain disk; recoverable by any script or filesystem reader", + "Secret exfil on log — logging middleware captures the token bytes in flight; a long-lived credential leaks to log retention", + ], + perf: [ + "Storage failure — storeFailed() surfaces to the user within 1 s so they know persistence didn't take", + ], + notes: [ + "During Persisting the request is in flight and no token has been minted — the token appears only on tokenStored.", + ], + }, + { + id: "revive", + name: "Revive", + fig: "05", + caps: ["RememberMe", "PasswordAuth"], + verb: "At cold start with a Persisted token, hand it to host.refresh() — driving the host Idle → Refreshing, never writing Authenticated.", + state: [["host.state", "AuthState"]], + behaviors: [ + { + sig: "revive()", + pre: "cold start, state = Persisted, host.state = Idle", + eff: "host.refresh() — host transitions Idle → Refreshing", + }, + ], + invariants: [ + "the persistent token cannot grant host.state = Authenticated directly — only host.refreshed(id) (backend-confirmed) reaches Authenticated", + "revive() is a side-effect, not a binding transition — the host's refreshed(id) re-establishes the session", + ], + failures: [ + "Direct-to-Authenticated — revive bypasses host.refresh() and writes Authenticated; the backend never re-validates and revoked tokens still work", + "Stale token at launch — revive() uses a token past its expiresAt; the backend rejects it and the user gets a confusing failure", + ], + perf: [ + "Cold-start revival — revive() drives host.state = Refreshing within 100 ms of launch (no flash of Idle UI)", + "Refresh round-trip — host refresh → refreshed(id) completes within 1 s on a stable network", + ], + notes: [ + "The consumer never sees Authenticated until the backend confirms — the Refreshing → Authenticated transition is what proves the user is still allowed access.", + ], + }, + { + id: "revoke", + name: "Revoke", + fig: "06", + caps: ["RememberMe", "PasswordAuth"], + verb: "On host sign-out, a backend revocation signal, or suspicious activity, clear the token and move to Revoked.", + state: [ + ["state", "RememberMeState"], + ["token", "lone PersistentToken"], + ], + behaviors: [ + { + sig: "revoke()", + pre: "state = Persisted; host signed out / token revoked / suspicious activity", + eff: "state ← Revoked, token ← ⊥", + }, + ], + invariants: [ + "revoke() clears the token atomically — no window where state = Revoked ∧ token ≠ ⊥", + "host.signOut() cascades to revoke() — local sign-out always invalidates the persistent token", + ], + failures: [ + "No revocation cascade — signOut does not fire revoke(); a stolen device still authenticates after the user 'signed out'", + "Token theft — a leaked token (XSS, malware, shared device) silently signs in as the user until expiry or revocation", + "Multi-device confusion — one device's revoke() doesn't propagate to others; the user signs out on their phone but the browser stays authenticated for hours", + ], + perf: [ + "Revocation propagation — revoke() clears local token bytes within 50 ms of the trigger", + ], + notes: [], + }, + { + id: "expire", + name: "Expire", + fig: "07", + caps: ["RememberMe", "PersistentToken"], + verb: "Drop the token when the wall clock crosses expiresAt; return to NotPersisted with no revocation noise.", + state: [ + ["state", "RememberMeState"], + ["token", "lone PersistentToken"], + ], + behaviors: [ + { + sig: "expired()", + pre: "state = Persisted, now ≥ token.expiresAt", + eff: "state ← NotPersisted, token ← ⊥", + }, + ], + invariants: [ + "expired() is reachable only from Persisted and only when the wall clock crosses expiresAt — TTL is absolute, not extended by use", + ], + failures: [ + "Sliding expiry — expired() deferred whenever the token is used; the token effectively never expires and defeats the TTL", + ], + perf: [ + "Expiry visibility — expired() is silent; the UI shows the host's normal Idle state, no false alarm to the user", + ], + notes: ["Expiry differs from revocation: it is silent and routine, not a security event."], + }, + { + id: "clear", + name: "Clear", + fig: "08", + caps: ["RememberMe"], + verb: "Move from Revoked to NotPersisted once the consumer has acknowledged the revocation.", + state: [["state", "RememberMeState"]], + behaviors: [{ sig: "clear()", pre: "state = Revoked", eff: "state ← NotPersisted" }], + invariants: [ + "no token exists in Revoked or NotPersisted — clear() only advances the lifecycle position", + ], + failures: [], + perf: [], + notes: [ + "clear() closes the loop: Revoked → NotPersisted, ready to persist again on the next opted-in sign-in.", + ], + }, + ], + + coreInvariants: [ + "token ≠ ⊥ ⟺ state = Persisted — token bytes exist only after tokenStored", + "state ∈ {Persisting, Persisted} ⟹ optIn = True — persistence is opt-in only", + "token.subject = host.identity.subject at issuance — no cross-account binding", + "a persistent token cannot reach Authenticated directly — it only feeds host.refresh(); only host.refreshed(id) (backend-confirmed) authenticates", + "revoke() clears the token atomically — no window where state = Revoked ∧ token ≠ ⊥", + "the token lives in platform-secure storage — never in plain localStorage", + "expired() is reachable only from Persisted, only when the wall clock crosses expiresAt — TTL is absolute", + ], + + composition: { + caps: [ + { id: "core", label: "RememberMe : Binding", sub: "state, optIn, token", core: true }, + { id: "host", label: "PasswordAuth", sub: "host.state, identity" }, + { id: "token", label: "PersistentToken", sub: "subject, expiresAt" }, + { id: "storage", label: "SecureStorage", sub: "Keychain · HttpOnly" }, + ], + funcs: ["Capture", "Trigger", "Issue", "Store", "Revive", "Revoke", "Expire", "Clear"], + links: [ + ["core", "Capture"], + ["core", "Trigger"], + ["host", "Trigger"], + ["token", "Issue"], + ["host", "Issue"], + ["storage", "Store"], + ["token", "Store"], + ["core", "Revive"], + ["host", "Revive"], + ["core", "Revoke"], + ["host", "Revoke"], + ["token", "Expire"], + ["core", "Expire"], + ["core", "Clear"], + ], + }, + + stateMachine: REMEMBER_ME_MACHINE, +} diff --git a/src/specimens/RememberMeSpecimen.vue b/src/specimens/RememberMeSpecimen.vue new file mode 100644 index 0000000..fd34687 --- /dev/null +++ b/src/specimens/RememberMeSpecimen.vue @@ -0,0 +1,466 @@ + + + + +