Compare commits
2 Commits
9c8ba9a752
...
062826e126
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
062826e126 | ||
|
|
f6b184406d |
@@ -161,7 +161,8 @@ const funcCount = computed(() => props.blueprint.functions.length)
|
|||||||
<h2 class="section">Lifecycle — state machine</h2>
|
<h2 class="section">Lifecycle — state machine</h2>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="cap">
|
<div class="cap">
|
||||||
<span>Composed lifecycle</span><span class="id">loadState : LoadState</span>
|
<span>{{ blueprint.stateMachine?.caption ?? "Composed lifecycle" }}</span
|
||||||
|
><span class="id">{{ blueprint.stateMachine?.field ?? "loadState : LoadState" }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="body svg-wrap"><StateMachine :machine="blueprint.stateMachine" /></div>
|
<div class="body svg-wrap"><StateMachine :machine="blueprint.stateMachine" /></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ const markers = (Object.keys(SM_COLORS) as (keyof typeof SM_COLORS)[]).map((k) =
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const init = computed(() => {
|
const init = computed(() => {
|
||||||
const loading = byId.value.loading
|
const start = byId.value[props.machine.initId ?? "loading"]
|
||||||
const cy = loading.y + loading.h / 2
|
const cy = start.y + start.h / 2
|
||||||
const e = edgePoint(loading, 26, cy)
|
const e = edgePoint(start, 26, cy)
|
||||||
return { cy, path: `M31 ${cy} L ${e[0]} ${e[1]}` }
|
return { cy, path: `M31 ${cy} L ${e[0]} ${e[1]}` }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -81,8 +81,27 @@ const KIND_LABELS: Record<SmKind, string> = {
|
|||||||
loadMore: "loadMore()",
|
loadMore: "loadMore()",
|
||||||
navigate: "navigate(δ)",
|
navigate: "navigate(δ)",
|
||||||
changeScope: "changeScope(s)",
|
changeScope: "changeScope(s)",
|
||||||
|
persist: "startPersist()",
|
||||||
|
store: "tokenStored(t)",
|
||||||
|
storeFail: "storeFailed()",
|
||||||
|
revoke: "revoke()",
|
||||||
|
expire: "expired()",
|
||||||
|
clear: "clear()",
|
||||||
}
|
}
|
||||||
const KIND_ORDER: SmKind[] = ["succeed", "fail", "refresh", "loadMore", "navigate", "changeScope"]
|
const KIND_ORDER: SmKind[] = [
|
||||||
|
"succeed",
|
||||||
|
"fail",
|
||||||
|
"refresh",
|
||||||
|
"loadMore",
|
||||||
|
"navigate",
|
||||||
|
"changeScope",
|
||||||
|
"persist",
|
||||||
|
"store",
|
||||||
|
"storeFail",
|
||||||
|
"revoke",
|
||||||
|
"expire",
|
||||||
|
"clear",
|
||||||
|
]
|
||||||
|
|
||||||
const legendItems = computed(() => {
|
const legendItems = computed(() => {
|
||||||
const present = new Set(props.machine.edges.map((e) => e.kind))
|
const present = new Set(props.machine.edges.map((e) => e.kind))
|
||||||
|
|||||||
@@ -58,7 +58,20 @@ export interface SmNode {
|
|||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SmKind = "succeed" | "fail" | "refresh" | "loadMore" | "navigate" | "changeScope"
|
export type SmKind =
|
||||||
|
| "succeed"
|
||||||
|
| "fail"
|
||||||
|
| "refresh"
|
||||||
|
| "loadMore"
|
||||||
|
| "navigate"
|
||||||
|
| "changeScope"
|
||||||
|
// RememberMe persistence lifecycle
|
||||||
|
| "persist"
|
||||||
|
| "store"
|
||||||
|
| "storeFail"
|
||||||
|
| "revoke"
|
||||||
|
| "expire"
|
||||||
|
| "clear"
|
||||||
|
|
||||||
export interface SmEdge {
|
export interface SmEdge {
|
||||||
from: string
|
from: string
|
||||||
@@ -71,6 +84,13 @@ export interface SmEdge {
|
|||||||
export interface StateMachine {
|
export interface StateMachine {
|
||||||
nodes: SmNode[]
|
nodes: SmNode[]
|
||||||
edges: SmEdge[]
|
edges: SmEdge[]
|
||||||
|
// id of the initial state (the [*] arrow points here). Defaults to "loading"
|
||||||
|
// for the LoadState-based machines; RememberMe starts at "notpersisted".
|
||||||
|
initId?: string
|
||||||
|
// Panel caption + field label shown above the diagram. Default to the
|
||||||
|
// composed-LoadState wording; capabilities with their own lifecycle override.
|
||||||
|
caption?: string
|
||||||
|
field?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SM_COLORS: Record<SmKind | "init", string> = {
|
export const SM_COLORS: Record<SmKind | "init", string> = {
|
||||||
@@ -80,6 +100,13 @@ export const SM_COLORS: Record<SmKind | "init", string> = {
|
|||||||
loadMore: "#8fd0ff",
|
loadMore: "#8fd0ff",
|
||||||
navigate: "#b79cff",
|
navigate: "#b79cff",
|
||||||
changeScope: "#67d0c0",
|
changeScope: "#67d0c0",
|
||||||
|
// RememberMe: green mint / red store-fail / magenta revoke / amber expiry / purple reset
|
||||||
|
persist: "#8fd0ff",
|
||||||
|
store: "#7fdca0",
|
||||||
|
storeFail: "#ff8f8f",
|
||||||
|
revoke: "#ff5c8a",
|
||||||
|
expire: "#ffb84d",
|
||||||
|
clear: "#b79cff",
|
||||||
init: "#4f7099",
|
init: "#4f7099",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import type { Blueprint } from "./blueprint"
|
|||||||
import { list } from "./listBlueprint"
|
import { list } from "./listBlueprint"
|
||||||
import { grid } from "./gridBlueprint"
|
import { grid } from "./gridBlueprint"
|
||||||
import { calendar } from "./calendarBlueprint"
|
import { calendar } from "./calendarBlueprint"
|
||||||
|
import { rememberMe } from "./rememberMeBlueprint"
|
||||||
import ListSpecimen from "@/specimens/ListSpecimen.vue"
|
import ListSpecimen from "@/specimens/ListSpecimen.vue"
|
||||||
import GridSpecimen from "@/specimens/GridSpecimen.vue"
|
import GridSpecimen from "@/specimens/GridSpecimen.vue"
|
||||||
import CalendarSpecimen from "@/specimens/CalendarSpecimen.vue"
|
import CalendarSpecimen from "@/specimens/CalendarSpecimen.vue"
|
||||||
|
import RememberMeSpecimen from "@/specimens/RememberMeSpecimen.vue"
|
||||||
|
|
||||||
export interface RegistryEntry {
|
export interface RegistryEntry {
|
||||||
blueprint: Blueprint
|
blueprint: Blueprint
|
||||||
@@ -20,6 +22,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
|||||||
list: { blueprint: list, specimen: ListSpecimen },
|
list: { blueprint: list, specimen: ListSpecimen },
|
||||||
grid: { blueprint: grid, specimen: GridSpecimen },
|
grid: { blueprint: grid, specimen: GridSpecimen },
|
||||||
calendar: { blueprint: calendar, specimen: CalendarSpecimen },
|
calendar: { blueprint: calendar, specimen: CalendarSpecimen },
|
||||||
|
"remember-me": { blueprint: rememberMe, specimen: RememberMeSpecimen },
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BLUEPRINTS: Blueprint[] = Object.values(REGISTRY).map((e) => e.blueprint)
|
export const BLUEPRINTS: Blueprint[] = Object.values(REGISTRY).map((e) => e.blueprint)
|
||||||
|
|||||||
305
src/data/rememberMeBlueprint.ts
Normal file
305
src/data/rememberMeBlueprint.ts
Normal file
@@ -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,
|
||||||
|
}
|
||||||
466
src/specimens/RememberMeSpecimen.vue
Normal file
466
src/specimens/RememberMeSpecimen.vue
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
|
||||||
|
const props = defineProps<{ activeId: string }>()
|
||||||
|
|
||||||
|
// RememberMe has almost no distinctive UI — only the opt-in checkbox at sign-in
|
||||||
|
// and the cold-start "welcoming you back" screen. So the specimen shows those two
|
||||||
|
// screens plus the underlying machinery the capability actually owns (the secure
|
||||||
|
// storage vault + the binding/host state), and each function highlights the
|
||||||
|
// region it drives — the same annotate-one-surface approach the Calendar uses.
|
||||||
|
|
||||||
|
type Screen = "signin" | "welcome" | "signedin"
|
||||||
|
type Region = "checkbox" | "button" | "host" | "token" | "vault" | "spinner" | "banner"
|
||||||
|
type Vault = "empty" | "minting" | "stored" | "cleared"
|
||||||
|
|
||||||
|
interface Frame {
|
||||||
|
screen: Screen
|
||||||
|
optIn: boolean
|
||||||
|
vault: Vault
|
||||||
|
stateBadge: string
|
||||||
|
hostBadge: string
|
||||||
|
hl?: Region
|
||||||
|
tag?: string
|
||||||
|
note?: string
|
||||||
|
banner?: { text: string; kind: "revoke" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const frame = computed<Frame>(() => {
|
||||||
|
switch (props.activeId) {
|
||||||
|
case "capture":
|
||||||
|
return {
|
||||||
|
screen: "signin",
|
||||||
|
optIn: true,
|
||||||
|
vault: "empty",
|
||||||
|
stateBadge: "state = NotPersisted",
|
||||||
|
hostBadge: "host.state = Idle",
|
||||||
|
hl: "checkbox",
|
||||||
|
tag: "◈ CAPTURE",
|
||||||
|
note: "optIn = True is captured at sign-in — persistence is opt-in, never default.",
|
||||||
|
}
|
||||||
|
case "trigger":
|
||||||
|
return {
|
||||||
|
screen: "signedin",
|
||||||
|
optIn: true,
|
||||||
|
vault: "empty",
|
||||||
|
stateBadge: "state = NotPersisted → Persisting",
|
||||||
|
hostBadge: "host.state = Idle → Authenticated",
|
||||||
|
hl: "host",
|
||||||
|
tag: "◈ TRIGGER",
|
||||||
|
note: "Host reaches Authenticated with optIn = True → startPersist() → state ← Persisting.",
|
||||||
|
}
|
||||||
|
case "issue":
|
||||||
|
return {
|
||||||
|
screen: "signedin",
|
||||||
|
optIn: true,
|
||||||
|
vault: "minting",
|
||||||
|
stateBadge: "state = Persisting",
|
||||||
|
hostBadge: "host.state = Authenticated",
|
||||||
|
hl: "token",
|
||||||
|
tag: "◈ ISSUE",
|
||||||
|
note: "Backend mints PersistentToken { subject = host.identity.subject, expiresAt = now + 30d }.",
|
||||||
|
}
|
||||||
|
case "store":
|
||||||
|
return {
|
||||||
|
screen: "signedin",
|
||||||
|
optIn: true,
|
||||||
|
vault: "stored",
|
||||||
|
stateBadge: "state = Persisting → Persisted",
|
||||||
|
hostBadge: "host.state = Authenticated",
|
||||||
|
hl: "vault",
|
||||||
|
tag: "◈ STORE",
|
||||||
|
note: "tokenStored(t) writes the bytes to platform-secure storage → state ← Persisted. Never localStorage.",
|
||||||
|
}
|
||||||
|
case "revive":
|
||||||
|
return {
|
||||||
|
screen: "welcome",
|
||||||
|
optIn: true,
|
||||||
|
vault: "stored",
|
||||||
|
stateBadge: "state = Persisted",
|
||||||
|
hostBadge: "host.state = Idle → Refreshing",
|
||||||
|
hl: "spinner",
|
||||||
|
tag: "◈ REVIVE",
|
||||||
|
note: "Cold start with a Persisted token → host.refresh() → Refreshing. The token never writes Authenticated directly.",
|
||||||
|
}
|
||||||
|
case "revoke":
|
||||||
|
return {
|
||||||
|
screen: "signedin",
|
||||||
|
optIn: true,
|
||||||
|
vault: "cleared",
|
||||||
|
stateBadge: "state = Persisted → Revoked",
|
||||||
|
hostBadge: "host.state = Authenticated → Idle",
|
||||||
|
hl: "banner",
|
||||||
|
tag: "◈ REVOKE",
|
||||||
|
banner: { text: "Session revoked — token invalidated", kind: "revoke" },
|
||||||
|
note: "signOut / backend signal / suspicious activity → revoke() clears the token atomically → Revoked.",
|
||||||
|
}
|
||||||
|
case "expire":
|
||||||
|
return {
|
||||||
|
screen: "signin",
|
||||||
|
optIn: true,
|
||||||
|
vault: "cleared",
|
||||||
|
stateBadge: "state = Persisted → NotPersisted",
|
||||||
|
hostBadge: "host.state = Idle",
|
||||||
|
hl: "vault",
|
||||||
|
tag: "◈ EXPIRE",
|
||||||
|
note: "now ≥ expiresAt → expired() silently drops the token; the UI shows the normal Idle sign-in. TTL is absolute.",
|
||||||
|
}
|
||||||
|
case "clear":
|
||||||
|
return {
|
||||||
|
screen: "signin",
|
||||||
|
optIn: true,
|
||||||
|
vault: "empty",
|
||||||
|
stateBadge: "state = Revoked → NotPersisted",
|
||||||
|
hostBadge: "host.state = Idle",
|
||||||
|
hl: "vault",
|
||||||
|
tag: "◈ CLEAR",
|
||||||
|
note: "Consumer acknowledged the revocation → clear() → NotPersisted; ready to persist again on the next opted-in sign-in.",
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
screen: "signin",
|
||||||
|
optIn: true,
|
||||||
|
vault: "empty",
|
||||||
|
stateBadge: "state = NotPersisted",
|
||||||
|
hostBadge: "host.state = Idle",
|
||||||
|
note: "The persistence lifecycle: opt in at sign-in → mint + store a token → revive the host on cold start. It only feeds host.refresh() — it never authenticates directly.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isHl = (r: Region) => frame.value.hl === r
|
||||||
|
const tagOf = (r: Region) => (frame.value.hl === r ? frame.value.tag : undefined)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="specimen">
|
||||||
|
<p v-if="frame.note" class="note">{{ frame.note }}</p>
|
||||||
|
|
||||||
|
<!-- the device the user sees -->
|
||||||
|
<div class="device">
|
||||||
|
<div class="dev-bar">
|
||||||
|
<span class="dot" /><span class="dot" /><span class="dot" />
|
||||||
|
<span class="dev-title">MyApp</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="screen">
|
||||||
|
<div
|
||||||
|
v-if="frame.banner"
|
||||||
|
class="banner"
|
||||||
|
:class="[frame.banner.kind, isHl('banner') ? ['hl', 'tag-b'] : []]"
|
||||||
|
:data-tag="tagOf('banner')"
|
||||||
|
>
|
||||||
|
⚠ {{ frame.banner.text }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- sign-in (opt-in) -->
|
||||||
|
<template v-if="frame.screen === 'signin'">
|
||||||
|
<div class="s-title">Sign in to MyApp</div>
|
||||||
|
<div class="field">user@example.com</div>
|
||||||
|
<div class="field secret">•••••••••</div>
|
||||||
|
<div
|
||||||
|
class="optrow"
|
||||||
|
:class="isHl('checkbox') ? ['hl', 'tag-b'] : []"
|
||||||
|
:data-tag="tagOf('checkbox')"
|
||||||
|
>
|
||||||
|
<span class="box">{{ frame.optIn ? "☑" : "☐" }}</span>
|
||||||
|
<span>Keep me signed in on this device</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="btn"
|
||||||
|
:class="isHl('button') ? ['hl', 'tag-b'] : []"
|
||||||
|
:data-tag="tagOf('button')"
|
||||||
|
>
|
||||||
|
Sign in <span class="arrow">→</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- cold-start revival -->
|
||||||
|
<template v-else-if="frame.screen === 'welcome'">
|
||||||
|
<div class="welcome">
|
||||||
|
<div class="w-title">Welcoming you back</div>
|
||||||
|
<div
|
||||||
|
class="spinner"
|
||||||
|
:class="isHl('spinner') ? ['hl', 'tag-b'] : []"
|
||||||
|
:data-tag="tagOf('spinner')"
|
||||||
|
>
|
||||||
|
<span class="spin">⟳</span> …
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- signed in -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="si-title">You're signed in</div>
|
||||||
|
<div class="idchip">u_84f · user@example.com</div>
|
||||||
|
<div class="btn ghost">Sign out</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- the machinery the capability owns -->
|
||||||
|
<div class="machinery">
|
||||||
|
<div
|
||||||
|
class="vault"
|
||||||
|
:class="isHl('vault') || isHl('token') ? ['hl', 'tag-b'] : []"
|
||||||
|
:data-tag="tagOf('vault') ?? tagOf('token')"
|
||||||
|
>
|
||||||
|
<div class="v-label">🔒 Secure storage · Keychain / HttpOnly</div>
|
||||||
|
<div class="v-body">
|
||||||
|
<span v-if="frame.vault === 'empty'" class="v-empty">— no token —</span>
|
||||||
|
<span v-else-if="frame.vault === 'minting'" class="tok minting">⬡ token · minting…</span>
|
||||||
|
<span v-else-if="frame.vault === 'stored'" class="tok">⬡ sub=u_84f · exp +30d</span>
|
||||||
|
<span v-else class="v-cleared">✗ token cleared</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="statebar">
|
||||||
|
<span class="badge">{{ frame.stateBadge }}</span>
|
||||||
|
<span
|
||||||
|
class="badge host"
|
||||||
|
:class="isHl('host') ? ['hl', 'tag-b'] : []"
|
||||||
|
:data-tag="tagOf('host')"
|
||||||
|
>
|
||||||
|
{{ frame.hostBadge }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.specimen {
|
||||||
|
--ink: var(--color-base-content);
|
||||||
|
--ink-faint: #4f7099;
|
||||||
|
--amber: var(--color-accent);
|
||||||
|
--amber-dim: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||||
|
--cyan: var(--color-secondary);
|
||||||
|
--green: #7fdca0;
|
||||||
|
--red: var(--color-error);
|
||||||
|
--paper: var(--color-base-100);
|
||||||
|
--line: rgba(200, 226, 255, 0.14);
|
||||||
|
--line-strong: rgba(200, 226, 255, 0.3);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.note {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: italic;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* device */
|
||||||
|
.device {
|
||||||
|
max-width: 340px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
background: var(--paper);
|
||||||
|
}
|
||||||
|
.dev-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.dev-bar .dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ink-faint);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.dev-title {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.screen {
|
||||||
|
padding: 20px 22px 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* sign-in */
|
||||||
|
.s-title {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ink);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: color-mix(in srgb, var(--ink) 75%, transparent);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.field.secret {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
}
|
||||||
|
.optrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 12px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: color-mix(in srgb, var(--ink) 85%, transparent);
|
||||||
|
}
|
||||||
|
.optrow .box {
|
||||||
|
color: var(--amber);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid var(--cyan);
|
||||||
|
background: color-mix(in srgb, var(--cyan) 16%, transparent);
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 8px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.btn .arrow {
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
.btn.ghost {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--line-strong);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* welcome / revive */
|
||||||
|
.welcome {
|
||||||
|
text-align: center;
|
||||||
|
padding: 22px 0 26px;
|
||||||
|
}
|
||||||
|
.w-title {
|
||||||
|
color: var(--ink);
|
||||||
|
margin-bottom: 18px;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--amber);
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.spinner .spin {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* signed in */
|
||||||
|
.si-title {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ink);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.idchip {
|
||||||
|
text-align: center;
|
||||||
|
border: 1px dashed var(--line-strong);
|
||||||
|
padding: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* banner */
|
||||||
|
.banner {
|
||||||
|
border: 1px solid var(--red);
|
||||||
|
background: color-mix(in srgb, var(--red) 14%, transparent);
|
||||||
|
color: var(--red);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* machinery */
|
||||||
|
.machinery {
|
||||||
|
max-width: 340px;
|
||||||
|
margin: 14px auto 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.vault {
|
||||||
|
border: 1px dashed var(--line-strong);
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.v-label {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.v-body {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.v-empty {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.tok {
|
||||||
|
display: inline-block;
|
||||||
|
border: 1px solid var(--green);
|
||||||
|
background: color-mix(in srgb, var(--green) 14%, transparent);
|
||||||
|
color: var(--green);
|
||||||
|
padding: 2px 8px;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.tok.minting {
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: var(--amber);
|
||||||
|
background: var(--amber-dim);
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
.v-cleared {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
.statebar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
color: var(--cyan);
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.badge.host {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* callouts — same convention as the other specimens */
|
||||||
|
.hl {
|
||||||
|
outline: 1.5px dashed var(--amber);
|
||||||
|
outline-offset: 3px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.hl[data-tag]::after {
|
||||||
|
content: attr(data-tag);
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
left: 8px;
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--amber);
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border: 1px solid var(--amber);
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
line-height: 1.5;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.hl.tag-b[data-tag]::after {
|
||||||
|
top: auto;
|
||||||
|
bottom: -10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user