Compare commits
2 Commits
5dff87a90d
...
1ba85e3235
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ba85e3235 | ||
|
|
4d2db834ac |
@@ -1,12 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, type Component } from "vue"
|
||||
import type { Blueprint } from "@/data/blueprint"
|
||||
import { slugForName } from "@/data/blueprints"
|
||||
import FunctionReadout from "@/components/FunctionReadout.vue"
|
||||
import CompositionMap from "@/components/CompositionMap.vue"
|
||||
import StateMachine from "@/components/StateMachine.vue"
|
||||
|
||||
const props = defineProps<{ blueprint: Blueprint; specimen: Component }>()
|
||||
|
||||
const extendsSlug = computed(() => slugForName(props.blueprint.extendsName))
|
||||
|
||||
const activeId = ref("full")
|
||||
// reset to the overview whenever we switch blueprints
|
||||
watch(
|
||||
@@ -47,7 +50,12 @@ const funcCount = computed(() => props.blueprint.functions.length)
|
||||
</div>
|
||||
<div class="tb-cell">
|
||||
<div class="tb-k">Extends</div>
|
||||
<div class="tb-v">{{ blueprint.extendsName ?? "—" }}</div>
|
||||
<div class="tb-v">
|
||||
<RouterLink v-if="extendsSlug" :to="`/b/${extendsSlug}`" class="xlink">{{
|
||||
blueprint.extendsName
|
||||
}}</RouterLink>
|
||||
<template v-else>{{ blueprint.extendsName ?? "—" }}</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tb-cell">
|
||||
<div class="tb-k">Role</div>
|
||||
@@ -168,6 +176,23 @@ const funcCount = computed(() => props.blueprint.functions.length)
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- COMPANION 3: related blueprints -->
|
||||
<template v-if="blueprint.related?.length">
|
||||
<h2 class="section">Related blueprints</h2>
|
||||
<div class="related">
|
||||
<template v-for="r in blueprint.related" :key="r.name">
|
||||
<RouterLink v-if="slugForName(r.name)" :to="`/b/${slugForName(r.name)}`" class="rel link">
|
||||
<span class="rel-name">{{ r.name }} <span class="rel-arrow">▸</span></span>
|
||||
<span class="rel-rel">{{ r.relation }}</span>
|
||||
</RouterLink>
|
||||
<div v-else class="rel">
|
||||
<span class="rel-name">{{ r.name }}</span>
|
||||
<span class="rel-rel">{{ r.relation }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<footer class="credit">
|
||||
<div>
|
||||
<span class="ck">SOURCE</span> blueprint-ontology / blueprints / {{ blueprint.slug }} ·
|
||||
@@ -456,6 +481,54 @@ code {
|
||||
color: #7ea6cd;
|
||||
}
|
||||
|
||||
/* cross-links */
|
||||
.xlink {
|
||||
color: var(--color-secondary);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dashed color-mix(in srgb, var(--color-secondary) 50%, transparent);
|
||||
}
|
||||
.xlink:hover {
|
||||
border-bottom-style: solid;
|
||||
}
|
||||
.related {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.rel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
border: 1px solid rgba(200, 226, 255, 0.14);
|
||||
padding: 8px 12px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.rel.link {
|
||||
border-color: rgba(200, 226, 255, 0.3);
|
||||
cursor: pointer;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.rel.link:hover {
|
||||
border-color: var(--color-secondary);
|
||||
background: var(--color-base-200);
|
||||
}
|
||||
.rel-name {
|
||||
font-size: 13px;
|
||||
color: #7ea6cd;
|
||||
}
|
||||
.rel.link .rel-name {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
.rel-arrow {
|
||||
font-size: 10px;
|
||||
}
|
||||
.rel-rel {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: #4f7099;
|
||||
}
|
||||
|
||||
.credit {
|
||||
margin-top: 34px;
|
||||
padding-top: 14px;
|
||||
|
||||
@@ -87,14 +87,24 @@ const KIND_LABELS: Record<SmKind, string> = {
|
||||
revoke: "revoke()",
|
||||
expire: "expired()",
|
||||
clear: "clear()",
|
||||
submit: "signIn(c)",
|
||||
lock: "lockOut()",
|
||||
signout: "signOut()",
|
||||
reset: "reset()",
|
||||
unlock: "unlock()",
|
||||
}
|
||||
const KIND_ORDER: SmKind[] = [
|
||||
"submit",
|
||||
"succeed",
|
||||
"fail",
|
||||
"refresh",
|
||||
"loadMore",
|
||||
"navigate",
|
||||
"changeScope",
|
||||
"lock",
|
||||
"signout",
|
||||
"reset",
|
||||
"unlock",
|
||||
"persist",
|
||||
"store",
|
||||
"storeFail",
|
||||
@@ -103,13 +113,24 @@ const KIND_ORDER: SmKind[] = [
|
||||
"clear",
|
||||
]
|
||||
|
||||
// Lay the legend out left-to-right, wrapping to a new row when it would overflow
|
||||
// the viewBox. Machines with few kinds (List, Grid, Calendar) stay on one row
|
||||
// exactly as before; richer machines (PasswordAuth) wrap instead of clipping.
|
||||
const legendItems = computed(() => {
|
||||
const present = new Set(props.machine.edges.map((e) => e.kind))
|
||||
const maxX = 880
|
||||
let lxp = 70
|
||||
let row = 0
|
||||
return KIND_ORDER.filter((k) => present.has(k)).map((k) => {
|
||||
const t = KIND_LABELS[k]
|
||||
const item = { t, color: SM_COLORS[k], x1: lxp, x2: lxp + 18, tx: lxp + 24, y: 8, ty: 12 }
|
||||
lxp += 24 + t.length * 6.6 + 26
|
||||
const width = 24 + t.length * 6.6 + 26
|
||||
if (lxp > 70 && lxp + width > maxX) {
|
||||
row += 1
|
||||
lxp = 70
|
||||
}
|
||||
const y = 8 + row * 16
|
||||
const item = { t, color: SM_COLORS[k], x1: lxp, x2: lxp + 18, tx: lxp + 24, y, ty: y + 4 }
|
||||
lxp += width
|
||||
return item
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,6 +72,12 @@ export type SmKind =
|
||||
| "revoke"
|
||||
| "expire"
|
||||
| "clear"
|
||||
// PasswordAuth session lifecycle
|
||||
| "submit"
|
||||
| "lock"
|
||||
| "signout"
|
||||
| "reset"
|
||||
| "unlock"
|
||||
|
||||
export interface SmEdge {
|
||||
from: string
|
||||
@@ -107,12 +113,26 @@ export const SM_COLORS: Record<SmKind | "init", string> = {
|
||||
revoke: "#ff5c8a",
|
||||
expire: "#ffb84d",
|
||||
clear: "#b79cff",
|
||||
// PasswordAuth: blue submit / magenta lockout / purple sign-out / teal recovery
|
||||
submit: "#8fd0ff",
|
||||
lock: "#ff5c8a",
|
||||
signout: "#b79cff",
|
||||
reset: "#67d0c0",
|
||||
unlock: "#4fb3a6",
|
||||
init: "#4f7099",
|
||||
}
|
||||
|
||||
// ── Blueprint ──────────────────────────────────────────────────────────────
|
||||
export type BlueprintRole = "feature" | "capability"
|
||||
|
||||
// A reference from one blueprint to another (host, base, sibling, extension).
|
||||
// `name` is the referenced blueprint's display name; the Viewer resolves it to a
|
||||
// live link when that blueprint is registered, otherwise renders it as plain text.
|
||||
export interface RelatedRef {
|
||||
name: string
|
||||
relation: string
|
||||
}
|
||||
|
||||
export interface Blueprint {
|
||||
slug: string
|
||||
name: string
|
||||
@@ -125,4 +145,5 @@ export interface Blueprint {
|
||||
coreInvariants: string[]
|
||||
composition?: Composition
|
||||
stateMachine?: StateMachine
|
||||
related?: RelatedRef[]
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ import { list } from "./listBlueprint"
|
||||
import { grid } from "./gridBlueprint"
|
||||
import { calendar } from "./calendarBlueprint"
|
||||
import { rememberMe } from "./rememberMeBlueprint"
|
||||
import { passwordAuth } from "./passwordAuthBlueprint"
|
||||
import ListSpecimen from "@/specimens/ListSpecimen.vue"
|
||||
import GridSpecimen from "@/specimens/GridSpecimen.vue"
|
||||
import CalendarSpecimen from "@/specimens/CalendarSpecimen.vue"
|
||||
import RememberMeSpecimen from "@/specimens/RememberMeSpecimen.vue"
|
||||
import PasswordAuthSpecimen from "@/specimens/PasswordAuthSpecimen.vue"
|
||||
|
||||
export interface RegistryEntry {
|
||||
blueprint: Blueprint
|
||||
@@ -22,6 +24,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
list: { blueprint: list, specimen: ListSpecimen },
|
||||
grid: { blueprint: grid, specimen: GridSpecimen },
|
||||
calendar: { blueprint: calendar, specimen: CalendarSpecimen },
|
||||
"password-auth": { blueprint: passwordAuth, specimen: PasswordAuthSpecimen },
|
||||
"remember-me": { blueprint: rememberMe, specimen: RememberMeSpecimen },
|
||||
}
|
||||
|
||||
@@ -30,3 +33,15 @@ export const BLUEPRINTS: Blueprint[] = Object.values(REGISTRY).map((e) => e.blue
|
||||
export function entryFor(slug: string): RegistryEntry | undefined {
|
||||
return REGISTRY[slug]
|
||||
}
|
||||
|
||||
// Resolve a blueprint's display name (as used in `extendsName` / `related`) to its
|
||||
// slug, so cross-references between blueprints render as live links. Returns
|
||||
// undefined when the referenced blueprint is not illustrated yet — the reference
|
||||
// then renders as plain text.
|
||||
const slugByName: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(REGISTRY).map(([slug, e]) => [e.blueprint.name, slug]),
|
||||
)
|
||||
|
||||
export function slugForName(name: string | undefined): string | undefined {
|
||||
return name ? slugByName[name] : undefined
|
||||
}
|
||||
|
||||
348
src/data/passwordAuthBlueprint.ts
Normal file
348
src/data/passwordAuthBlueprint.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
// The PasswordAuth blueprint, as data. Transcribed by hand from the ontology
|
||||
// `password-auth` README + password-auth.als. A *feature* that extends Form: the
|
||||
// user submits a credential, the backend verifies it, and a bounded session is
|
||||
// established — with uniform failures (no username enumeration), lockout, and
|
||||
// silent refresh. It is the host that RememberMe / MFA / Captcha compose onto.
|
||||
|
||||
import type { Blueprint, StateMachine } from "./blueprint"
|
||||
|
||||
// The full AuthState lifecycle. Idle → Submitting fans out to Authenticated /
|
||||
// Error / LockedOut; Authenticated ⇄ Refreshing; and reset() / unlock() / signOut()
|
||||
// all return to Idle. Node coordinates are hand-placed for a clean layout.
|
||||
const PASSWORD_AUTH_MACHINE: StateMachine = {
|
||||
initId: "idle",
|
||||
caption: "Session lifecycle",
|
||||
field: "state : AuthState",
|
||||
nodes: [
|
||||
{ id: "idle", x: 30, y: 140, w: 90, h: 44, label: "Idle" },
|
||||
{ id: "submitting", x: 190, y: 140, w: 120, h: 44, label: "Submitting" },
|
||||
{ id: "authenticated", x: 400, y: 140, w: 140, h: 44, label: "Authenticated" },
|
||||
{ id: "refreshing", x: 630, y: 44, w: 130, h: 44, label: "Refreshing" },
|
||||
{ id: "lockedout", x: 175, y: 252, w: 120, h: 44, label: "LockedOut" },
|
||||
{ id: "error", x: 395, y: 252, w: 100, h: 44, label: "Error" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "idle", to: "submitting", label: "signIn(c)", kind: "submit", off: -34 },
|
||||
{ from: "submitting", to: "authenticated", label: "succeed(id)", kind: "succeed", off: -34 },
|
||||
{ from: "submitting", to: "lockedout", label: "lockOut()", kind: "lock", off: -20 },
|
||||
{ from: "submitting", to: "error", label: "fail()", kind: "fail", off: -24 },
|
||||
{ from: "authenticated", to: "refreshing", label: "refresh()", kind: "refresh", off: -26 },
|
||||
{ from: "refreshing", to: "authenticated", label: "refreshed(id)", kind: "succeed", off: -26 },
|
||||
{ from: "refreshing", to: "error", label: "refreshFailed()", kind: "fail", off: -30 },
|
||||
{ from: "authenticated", to: "idle", label: "signOut()", kind: "signout", off: 120 },
|
||||
{ from: "error", to: "idle", label: "reset()", kind: "reset", off: -40 },
|
||||
{ from: "lockedout", to: "idle", label: "unlock()", kind: "unlock", off: 30 },
|
||||
],
|
||||
}
|
||||
|
||||
export const passwordAuth: Blueprint = {
|
||||
slug: "password-auth",
|
||||
name: "PasswordAuth",
|
||||
tagline:
|
||||
"Submit a credential, verify it against the backend, and hold a bounded session — with uniform failures, lockout, and silent refresh.",
|
||||
role: "feature",
|
||||
extendsName: "Form",
|
||||
|
||||
sig: {
|
||||
header: "PasswordAuth ◁ Form",
|
||||
fields: [
|
||||
{ name: "state", type: "AuthState", note: "lifecycle position" },
|
||||
{ name: "identity", type: "lone Identity", note: "present iff Authenticated or Refreshing" },
|
||||
{
|
||||
name: "pendingCredential",
|
||||
type: "lone Credential",
|
||||
note: "present only during Submitting",
|
||||
},
|
||||
],
|
||||
types: [
|
||||
"AuthState = Idle | Submitting | Authenticated | Refreshing | LockedOut | Error",
|
||||
"Identity { subject : Subject, realm : Realm } -- subject unique per realm",
|
||||
"Credential { username : Username, secret : Secret } -- 8 ≤ len(secret) ≤ 128",
|
||||
"identity ≠ ⊥ ⟺ state ∈ {Authenticated, Refreshing}",
|
||||
"pendingCredential ≠ ⊥ ⟺ state = Submitting",
|
||||
],
|
||||
},
|
||||
|
||||
functions: [
|
||||
{
|
||||
id: "capture",
|
||||
name: "Capture",
|
||||
fig: "01",
|
||||
caps: ["PasswordAuth", "Form"],
|
||||
verb: "Collect username and secret from the form; mask the secret and mount both fields together so the OS credential manager can pair them.",
|
||||
state: [
|
||||
["username", "Username"],
|
||||
["secret", "Secret"],
|
||||
],
|
||||
behaviors: [],
|
||||
invariants: [
|
||||
"len(secret) ∈ [8, 128] — enforced before signIn(c) fires; the upper bound is surfaced to the user, never silently accepted (bcrypt truncates at 72 bytes)",
|
||||
],
|
||||
failures: [
|
||||
"Form auto-submit — browser autofill triggers signIn without user intent; the unintended submission locks the user out faster on stale credentials",
|
||||
],
|
||||
perf: [
|
||||
"Form responsiveness — input fields remain accessible during Submitting; the submit button is disabled to prevent double-fire",
|
||||
],
|
||||
notes: [
|
||||
"Username and password are mounted together on the submitted form so the credential manager can pair them on save.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "submit",
|
||||
name: "Submit",
|
||||
fig: "02",
|
||||
caps: ["PasswordAuth"],
|
||||
verb: "Transmit the credential over an authenticated channel (TLS) on a user gesture; never log or persist the secret.",
|
||||
state: [
|
||||
["state", "AuthState"],
|
||||
["pendingCredential", "lone Credential"],
|
||||
],
|
||||
behaviors: [
|
||||
{
|
||||
sig: "signIn(c)",
|
||||
pre: "state = Idle",
|
||||
eff: "state ← Submitting, pendingCredential ← c",
|
||||
},
|
||||
],
|
||||
invariants: [
|
||||
"pendingCredential ≠ ⊥ ⟺ state = Submitting — the credential is held only across the verification call",
|
||||
"the plaintext secret is never persisted, logged, or held past Submitting",
|
||||
],
|
||||
failures: [
|
||||
"Plaintext leak — secret logged, persisted, or kept in app memory past Submitting; exposed to crash dumps, analytics, or an attacker with disk read",
|
||||
"Form auto-submit — autofill fires signIn without a user gesture",
|
||||
],
|
||||
perf: [
|
||||
"Verification — signIn to Authenticated or Error completes within 2 s on a stable network",
|
||||
],
|
||||
notes: [
|
||||
"The signIn(c) submission must be triggered by a user gesture, never auto-fired on autofill completion.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "verify",
|
||||
name: "Verify",
|
||||
fig: "03",
|
||||
caps: ["PasswordAuth"],
|
||||
verb: "Backend checks the credential against the credential store; returns an Identity or a uniform failure that never distinguishes unknown-user from wrong-password.",
|
||||
state: [
|
||||
["state", "AuthState"],
|
||||
["identity", "lone Identity"],
|
||||
],
|
||||
behaviors: [
|
||||
{
|
||||
sig: "succeed(id)",
|
||||
pre: "state = Submitting, verification passed",
|
||||
eff: "state ← Authenticated, identity ← id, pendingCredential ← ⊥",
|
||||
},
|
||||
{
|
||||
sig: "fail()",
|
||||
pre: "state = Submitting, verification failed",
|
||||
eff: "state ← Error, pendingCredential ← ⊥",
|
||||
},
|
||||
],
|
||||
invariants: [
|
||||
"succeed(id) requires backend verification — the relying party never trusts client-side validation alone",
|
||||
"fail() does not distinguish 'wrong password' from 'unknown username' — messages stay uniform",
|
||||
],
|
||||
failures: [
|
||||
"Username enumeration — fail() reason distinguishes 'no such user' from 'wrong password'; an attacker maps valid usernames before a targeted attack",
|
||||
"Timing attack — verification time differs for valid vs invalid usernames; the attacker infers validity from response latency",
|
||||
"Credential reuse — the backend accepts a known-breached password, so a password compromised elsewhere grants access here",
|
||||
],
|
||||
perf: [
|
||||
"Constant-time response — failure responses take the same time regardless of username validity",
|
||||
"Failure visibility — Error surfaces with a uniform message, never revealing whether the username exists",
|
||||
],
|
||||
notes: [],
|
||||
},
|
||||
{
|
||||
id: "limit",
|
||||
name: "Limit",
|
||||
fig: "04",
|
||||
caps: ["PasswordAuth"],
|
||||
verb: "Track failed attempts per user / per IP; transition to LockedOut past the threshold.",
|
||||
state: [["state", "AuthState"]],
|
||||
behaviors: [
|
||||
{
|
||||
sig: "lockOut()",
|
||||
pre: "state = Submitting, attempt threshold exceeded",
|
||||
eff: "state ← LockedOut, pendingCredential ← ⊥",
|
||||
},
|
||||
],
|
||||
invariants: [
|
||||
"state = LockedOut is reached only from Submitting via lockOut() — never user-triggerable directly",
|
||||
],
|
||||
failures: [
|
||||
"Brute force — no attempt counting / no lockOut threshold; the attacker tries unbounded password guesses",
|
||||
"Lockout bypass — unlock() callable without elapsed cooldown; the rate limit is defeated and brute force resumes",
|
||||
],
|
||||
perf: [
|
||||
"Lockout feedback — LockedOut surfaces to the user within 1 s of trigger, with the cooldown duration shown",
|
||||
],
|
||||
notes: [],
|
||||
},
|
||||
{
|
||||
id: "establish",
|
||||
name: "Establish",
|
||||
fig: "05",
|
||||
caps: ["PasswordAuth", "Identity"],
|
||||
verb: "Persist the session token; expose subject and realm to the consumer.",
|
||||
state: [["identity", "lone Identity"]],
|
||||
behaviors: [],
|
||||
invariants: [
|
||||
"identity ≠ ⊥ ⟺ state ∈ {Authenticated, Refreshing} — no ghost identity outside an active session",
|
||||
"the consumer sees subject and realm; the plaintext secret never crosses the client-persistence boundary",
|
||||
],
|
||||
failures: [
|
||||
"Replay (token theft) — the session token leaks (XSS, network capture); the attacker impersonates the user until the token expires",
|
||||
],
|
||||
perf: [],
|
||||
notes: [
|
||||
"Client persistence may store Identity.subject / Identity.realm and the composed session credential — never the plaintext secret in any form.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "refresh",
|
||||
name: "Refresh",
|
||||
fig: "06",
|
||||
caps: ["PasswordAuth"],
|
||||
verb: "Renew the session token silently before expiry; preserve subject and realm.",
|
||||
state: [
|
||||
["state", "AuthState"],
|
||||
["identity", "lone Identity"],
|
||||
],
|
||||
behaviors: [
|
||||
{
|
||||
sig: "refresh()",
|
||||
pre: "state = Authenticated, session token refreshable",
|
||||
eff: "state ← Refreshing",
|
||||
},
|
||||
{
|
||||
sig: "refreshed(id)",
|
||||
pre: "state = Refreshing, id.subject = identity.subject, id.realm = identity.realm",
|
||||
eff: "state ← Authenticated, identity ← id",
|
||||
},
|
||||
{
|
||||
sig: "refreshFailed()",
|
||||
pre: "state = Refreshing",
|
||||
eff: "state ← Error, identity ← ⊥",
|
||||
},
|
||||
],
|
||||
invariants: [
|
||||
"refreshed(id) preserves both subject and realm — refresh never silently swaps user or tenant",
|
||||
],
|
||||
failures: [
|
||||
"Subject swap on refresh — refreshed(id) accepts an id with a different subject or realm; the attacker's refresh response steals the session",
|
||||
"Stale session — the session is used past expiry without refresh; API calls 401 and the user is surprise-logged-out",
|
||||
],
|
||||
perf: [
|
||||
"Silent refresh — completes within 200 ms and before any in-flight API call observes a 401",
|
||||
],
|
||||
notes: [],
|
||||
},
|
||||
{
|
||||
id: "recover",
|
||||
name: "Recover",
|
||||
fig: "07",
|
||||
caps: ["PasswordAuth"],
|
||||
verb: "Surface Error distinctly from LockedOut; expose reset() and the cooldown-driven unlock().",
|
||||
state: [["state", "AuthState"]],
|
||||
behaviors: [
|
||||
{ sig: "reset()", pre: "state = Error", eff: "state ← Idle" },
|
||||
{
|
||||
sig: "unlock()",
|
||||
pre: "state = LockedOut, cooldown elapsed",
|
||||
eff: "state ← Idle",
|
||||
},
|
||||
],
|
||||
invariants: [
|
||||
"unlock() is reachable only from LockedOut and requires elapsed cooldown — not user-triggerable",
|
||||
],
|
||||
failures: [
|
||||
"Forgotten password loop — no recovery path from LockedOut or an unknown username; the user is permanently locked out with no self-service unblock",
|
||||
],
|
||||
perf: [],
|
||||
notes: [
|
||||
"Recovery from a forgotten password is a sibling blueprint (PasswordReset), not part of this contract.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "terminate",
|
||||
name: "Terminate",
|
||||
fig: "08",
|
||||
caps: ["PasswordAuth"],
|
||||
verb: "Clear the local session and invalidate the session token at the backend.",
|
||||
state: [
|
||||
["state", "AuthState"],
|
||||
["identity", "lone Identity"],
|
||||
],
|
||||
behaviors: [
|
||||
{
|
||||
sig: "signOut()",
|
||||
pre: "state = Authenticated",
|
||||
eff: "state ← Idle, identity ← ⊥",
|
||||
},
|
||||
],
|
||||
invariants: ["signOut() clears identity — no active session remains locally"],
|
||||
failures: [
|
||||
"Stale session — a logged-out session token still valid at the backend can be replayed",
|
||||
],
|
||||
perf: [],
|
||||
notes: [
|
||||
"A logout is not a credential operation — the secret never crosses the logout boundary; only the session credential is sent so the server can revoke.",
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
coreInvariants: [
|
||||
"identity ≠ ⊥ ⟺ state ∈ {Authenticated, Refreshing} — no ghost identity outside an active session",
|
||||
"pendingCredential ≠ ⊥ ⟺ state = Submitting — credentials are held only across the verification call",
|
||||
"the plaintext secret is never persisted, logged, or held past Submitting",
|
||||
"refreshed(id) preserves both subject and realm — refresh never swaps user or tenant",
|
||||
"succeed(id) requires backend verification — the relying party never trusts client-side validation alone",
|
||||
"unlock() is reachable only from LockedOut and requires elapsed cooldown — not user-triggerable",
|
||||
"len(secret) ∈ [8, 128] is enforced before signIn(c) fires",
|
||||
],
|
||||
|
||||
composition: {
|
||||
caps: [
|
||||
{ id: "core", label: "PasswordAuth : Form", sub: "state, identity", core: true },
|
||||
{ id: "form", label: "Form", sub: "fields, filled, invalid" },
|
||||
{ id: "cred", label: "Credential", sub: "username, secret" },
|
||||
{ id: "identity", label: "Identity", sub: "subject, realm" },
|
||||
],
|
||||
funcs: ["Capture", "Submit", "Verify", "Limit", "Establish", "Refresh", "Recover", "Terminate"],
|
||||
links: [
|
||||
["form", "Capture"],
|
||||
["cred", "Capture"],
|
||||
["cred", "Submit"],
|
||||
["core", "Submit"],
|
||||
["core", "Verify"],
|
||||
["identity", "Verify"],
|
||||
["core", "Limit"],
|
||||
["core", "Establish"],
|
||||
["identity", "Establish"],
|
||||
["core", "Refresh"],
|
||||
["identity", "Refresh"],
|
||||
["core", "Recover"],
|
||||
["form", "Recover"],
|
||||
["core", "Terminate"],
|
||||
["identity", "Terminate"],
|
||||
],
|
||||
},
|
||||
|
||||
stateMachine: PASSWORD_AUTH_MACHINE,
|
||||
|
||||
related: [
|
||||
{ name: "Form", relation: "extends · base" },
|
||||
{ name: "RememberMe", relation: "composable extension" },
|
||||
{ name: "MFA", relation: "composable extension" },
|
||||
{ name: "Captcha", relation: "composable extension" },
|
||||
{ name: "StatelessSession", relation: "composable extension" },
|
||||
{ name: "PasswordReset", relation: "sibling · recovery" },
|
||||
{ name: "SSO", relation: "sibling · delegated" },
|
||||
{ name: "Passkey", relation: "sibling · passwordless" },
|
||||
],
|
||||
}
|
||||
@@ -302,4 +302,11 @@ export const rememberMe: Blueprint = {
|
||||
},
|
||||
|
||||
stateMachine: REMEMBER_ME_MACHINE,
|
||||
|
||||
related: [
|
||||
{ name: "PasswordAuth", relation: "host · requires" },
|
||||
{ name: "StatelessSession", relation: "sibling capability" },
|
||||
{ name: "MFA", relation: "sibling capability" },
|
||||
{ name: "Captcha", relation: "sibling capability" },
|
||||
],
|
||||
}
|
||||
|
||||
430
src/specimens/PasswordAuthSpecimen.vue
Normal file
430
src/specimens/PasswordAuthSpecimen.vue
Normal file
@@ -0,0 +1,430 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
|
||||
const props = defineProps<{ activeId: string }>()
|
||||
|
||||
// PasswordAuth is a Form feature, so the specimen is a real sign-in card shown in
|
||||
// its lifecycle states — Idle / Submitting / Error / LockedOut / Authenticated /
|
||||
// Refreshing — and each function highlights the region it owns.
|
||||
|
||||
type Screen = "form" | "authed"
|
||||
type Region =
|
||||
| "fields"
|
||||
| "button"
|
||||
| "verify"
|
||||
| "attempts"
|
||||
| "identity"
|
||||
| "refresh"
|
||||
| "error"
|
||||
| "signout"
|
||||
|
||||
interface Frame {
|
||||
screen: Screen
|
||||
stateBadge: string
|
||||
error?: string
|
||||
locked?: string
|
||||
submitting?: boolean
|
||||
attempts?: number
|
||||
identity?: boolean
|
||||
refreshing?: boolean
|
||||
hl?: Region
|
||||
tag?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
const frame = computed<Frame>(() => {
|
||||
switch (props.activeId) {
|
||||
case "capture":
|
||||
return {
|
||||
screen: "form",
|
||||
stateBadge: "state = Idle",
|
||||
hl: "fields",
|
||||
tag: "◈ CAPTURE",
|
||||
note: "Collect username + secret; mask the secret and mount both fields together for the OS credential manager. 8 ≤ len(secret) ≤ 128.",
|
||||
}
|
||||
case "submit":
|
||||
return {
|
||||
screen: "form",
|
||||
stateBadge: "state = Idle → Submitting",
|
||||
submitting: true,
|
||||
hl: "button",
|
||||
tag: "◈ SUBMIT",
|
||||
note: "signIn(c) fires on a user gesture → Submitting. The secret crosses over TLS and is never logged or persisted.",
|
||||
}
|
||||
case "verify":
|
||||
return {
|
||||
screen: "form",
|
||||
stateBadge: "state = Submitting",
|
||||
submitting: true,
|
||||
hl: "verify",
|
||||
tag: "◈ VERIFY",
|
||||
note: "Backend checks the credential → succeed(id) or a uniform fail(). No message distinguishes unknown-user from wrong-password.",
|
||||
}
|
||||
case "limit":
|
||||
return {
|
||||
screen: "form",
|
||||
stateBadge: "state = Submitting → LockedOut",
|
||||
locked: "Too many attempts — try again in 4:59",
|
||||
attempts: 3,
|
||||
hl: "attempts",
|
||||
tag: "◈ LIMIT",
|
||||
note: "Past the failed-attempt threshold → lockOut() → LockedOut. Never user-triggerable; the cooldown is shown.",
|
||||
}
|
||||
case "establish":
|
||||
return {
|
||||
screen: "authed",
|
||||
stateBadge: "state = Authenticated",
|
||||
identity: true,
|
||||
hl: "identity",
|
||||
tag: "◈ ESTABLISH",
|
||||
note: "succeed(id) establishes the session; the consumer sees subject + realm. The plaintext secret never crosses the persistence boundary.",
|
||||
}
|
||||
case "refresh":
|
||||
return {
|
||||
screen: "authed",
|
||||
stateBadge: "state = Authenticated → Refreshing",
|
||||
identity: true,
|
||||
refreshing: true,
|
||||
hl: "refresh",
|
||||
tag: "◈ REFRESH",
|
||||
note: "Silent refresh renews the token within 200 ms, preserving subject + realm — before any in-flight call observes a 401.",
|
||||
}
|
||||
case "recover":
|
||||
return {
|
||||
screen: "form",
|
||||
stateBadge: "state = Error",
|
||||
error: "Incorrect email or password",
|
||||
hl: "error",
|
||||
tag: "◈ RECOVER",
|
||||
note: "Error surfaces with a uniform message. reset() clears it; unlock() returns from LockedOut after cooldown — never revealing whether the username exists.",
|
||||
}
|
||||
case "terminate":
|
||||
return {
|
||||
screen: "authed",
|
||||
stateBadge: "state = Authenticated → Idle",
|
||||
identity: true,
|
||||
hl: "signout",
|
||||
tag: "◈ TERMINATE",
|
||||
note: "signOut() clears the local session and invalidates the token at the backend. The secret never crosses the logout boundary.",
|
||||
}
|
||||
default:
|
||||
return {
|
||||
screen: "form",
|
||||
stateBadge: "state = Idle",
|
||||
note: "Submit a credential → verify against the backend → establish a bounded session. Failures stay uniform; lockout gates brute force; refresh renews silently.",
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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>
|
||||
|
||||
<div class="device">
|
||||
<div class="dev-bar">
|
||||
<span class="dot" /><span class="dot" /><span class="dot" />
|
||||
<span class="dev-title">MyApp</span>
|
||||
</div>
|
||||
|
||||
<!-- sign-in form -->
|
||||
<div v-if="frame.screen === 'form'" class="screen">
|
||||
<div
|
||||
v-if="frame.error"
|
||||
class="banner error"
|
||||
:class="isHl('error') ? ['hl', 'tag-b'] : []"
|
||||
:data-tag="tagOf('error')"
|
||||
>
|
||||
✗ {{ frame.error }}
|
||||
</div>
|
||||
<div v-if="frame.locked" class="banner locked">🔒 {{ frame.locked }}</div>
|
||||
|
||||
<div class="s-title">Sign in to MyApp</div>
|
||||
|
||||
<div
|
||||
class="fields"
|
||||
:class="isHl('fields') ? ['hl', 'tag-b'] : []"
|
||||
:data-tag="tagOf('fields')"
|
||||
>
|
||||
<div class="field">user@example.com</div>
|
||||
<div class="field secret">•••••••••</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="frame.attempts"
|
||||
class="attempts"
|
||||
:class="isHl('attempts') ? ['hl', 'tag-b'] : []"
|
||||
:data-tag="tagOf('attempts')"
|
||||
>
|
||||
<span v-for="n in 3" :key="n" class="pip" :class="{ used: n <= frame.attempts }">✕</span>
|
||||
<span class="attempts-note">{{ frame.attempts }} / 3 attempts</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="btn"
|
||||
:class="[{ busy: frame.submitting }, isHl('button') ? ['hl', 'tag-b'] : []]"
|
||||
:data-tag="tagOf('button')"
|
||||
>
|
||||
<template v-if="frame.submitting"><span class="spin">⟳</span> Signing in…</template>
|
||||
<template v-else>Sign in <span class="arrow">→</span></template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="frame.submitting"
|
||||
class="verifyline"
|
||||
:class="isHl('verify') ? ['hl', 'tag-b'] : []"
|
||||
:data-tag="tagOf('verify')"
|
||||
>
|
||||
→ verifying credential with backend…
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- signed-in -->
|
||||
<div v-else class="screen">
|
||||
<div class="si-title">You're signed in</div>
|
||||
<div
|
||||
class="idchip"
|
||||
:class="isHl('identity') ? ['hl', 'tag-b'] : []"
|
||||
:data-tag="tagOf('identity')"
|
||||
>
|
||||
subject = u_84f · realm = myapp
|
||||
</div>
|
||||
<div
|
||||
v-if="frame.refreshing"
|
||||
class="refresh-badge"
|
||||
:class="isHl('refresh') ? ['hl', 'tag-b'] : []"
|
||||
:data-tag="tagOf('refresh')"
|
||||
>
|
||||
<span class="spin">⟳</span> refreshing session…
|
||||
</div>
|
||||
<div
|
||||
class="btn ghost"
|
||||
:class="isHl('signout') ? ['hl', 'tag-b'] : []"
|
||||
:data-tag="tagOf('signout')"
|
||||
>
|
||||
Sign out
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="machinery">
|
||||
<div class="statebar">
|
||||
<span class="badge">{{ frame.stateBadge }}</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);
|
||||
--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;
|
||||
}
|
||||
.s-title,
|
||||
.si-title {
|
||||
text-align: center;
|
||||
color: var(--ink);
|
||||
margin-bottom: 14px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* fields */
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.field {
|
||||
border: 1px solid var(--line);
|
||||
padding: 8px 10px;
|
||||
color: color-mix(in srgb, var(--ink) 75%, transparent);
|
||||
font-size: 12px;
|
||||
}
|
||||
.field.secret {
|
||||
color: var(--ink-faint);
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
|
||||
/* attempts */
|
||||
.attempts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.pip {
|
||||
color: var(--line-strong);
|
||||
}
|
||||
.pip.used {
|
||||
color: var(--red);
|
||||
}
|
||||
.attempts-note {
|
||||
margin-left: 4px;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* button */
|
||||
.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;
|
||||
margin-top: 12px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.btn .arrow {
|
||||
color: var(--cyan);
|
||||
}
|
||||
.btn.busy {
|
||||
border-color: var(--line-strong);
|
||||
color: var(--ink-faint);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
border-color: var(--line-strong);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.verifyline {
|
||||
margin-top: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* banners */
|
||||
.banner {
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
margin-bottom: 14px;
|
||||
text-align: center;
|
||||
border: 1px solid;
|
||||
}
|
||||
.banner.error {
|
||||
border-color: var(--red);
|
||||
background: color-mix(in srgb, var(--red) 14%, transparent);
|
||||
color: var(--red);
|
||||
}
|
||||
.banner.locked {
|
||||
border-color: var(--amber);
|
||||
background: var(--amber-dim);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
/* signed-in */
|
||||
.idchip {
|
||||
text-align: center;
|
||||
border: 1px dashed var(--line-strong);
|
||||
padding: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--cyan);
|
||||
}
|
||||
.refresh-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
color: var(--amber);
|
||||
font-size: 11px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* machinery */
|
||||
.machinery {
|
||||
max-width: 340px;
|
||||
margin: 14px auto 0;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 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