@pattern-js/mod-identity 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/deliver.d.ts +6 -0
- package/dist/deliver.d.ts.map +1 -1
- package/dist/deliver.js +32 -5
- package/dist/deliver.js.map +1 -1
- package/dist/frontend.d.ts +7 -4
- package/dist/frontend.d.ts.map +1 -1
- package/dist/frontend.js +20 -63
- package/dist/frontend.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/mod.d.ts.map +1 -1
- package/dist/mod.js +10 -3
- package/dist/mod.js.map +1 -1
- package/dist/ops.d.ts.map +1 -1
- package/dist/ops.js +86 -13
- package/dist/ops.js.map +1 -1
- package/dist/options.d.ts +24 -0
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +4 -0
- package/dist/options.js.map +1 -1
- package/dist/pages/html.d.ts.map +1 -1
- package/dist/pages/html.js +15 -0
- package/dist/pages/html.js.map +1 -1
- package/dist/pages/login.d.ts +14 -2
- package/dist/pages/login.d.ts.map +1 -1
- package/dist/pages/login.js +21 -3
- package/dist/pages/login.js.map +1 -1
- package/dist/service.d.ts +44 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +67 -4
- package/dist/service.js.map +1 -1
- package/dist/store/memory.d.ts.map +1 -1
- package/dist/store/memory.js +0 -0
- package/dist/store/memory.js.map +1 -1
- package/dist/store/migrations.d.ts.map +1 -1
- package/dist/store/migrations.js +8 -0
- package/dist/store/migrations.js.map +1 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +28 -2
- package/dist/store/sqlite.js.map +1 -1
- package/dist/store/types.d.ts +24 -0
- package/dist/store/types.d.ts.map +1 -1
- package/dist/store/types.js.map +1 -1
- package/dist/tokens.d.ts +13 -1
- package/dist/tokens.d.ts.map +1 -1
- package/dist/tokens.js +20 -2
- package/dist/tokens.js.map +1 -1
- package/dist/user-page.d.ts +13 -0
- package/dist/user-page.d.ts.map +1 -0
- package/dist/user-page.js +204 -0
- package/dist/user-page.js.map +1 -0
- package/dist/workflows.d.ts +2 -1
- package/dist/workflows.d.ts.map +1 -1
- package/dist/workflows.js +13 -4
- package/dist/workflows.js.map +1 -1
- package/docs/index.md +75 -11
- package/docs/ops/identity.code.callback.md +17 -0
- package/docs/ops/identity.token.callback.md +3 -1
- package/docs/ops/identity.users.setRoles.md +5 -4
- package/package.json +5 -5
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pattern-js/mod-identity — the Tier-2 "User" details page.
|
|
3
|
+
*
|
|
4
|
+
* The operator's view of one person: identity card + the actions that matter
|
|
5
|
+
* (sign-in link, disable, log out everywhere, delete), a ROLES editor that
|
|
6
|
+
* knows the configured roles map (checkboxes, not comma syntax) and says out
|
|
7
|
+
* loud that saving signs the user out everywhere, their sessions and run
|
|
8
|
+
* stats — and, duck-typed, their subscription when mod-billing is installed
|
|
9
|
+
* (absent mod, absent panel). Same construction as the billing/email pages:
|
|
10
|
+
* ESM source over the shared `__PATTERN_ADMIN__` global.
|
|
11
|
+
*/
|
|
12
|
+
export const USER_PAGE_REMOTE = `
|
|
13
|
+
const { React, api, ui } = globalThis.__PATTERN_ADMIN__;
|
|
14
|
+
const { GlassPanel, NeonButton, Badge, Modal } = ui;
|
|
15
|
+
const h = React.createElement;
|
|
16
|
+
const inputCls = "glass w-full rounded-lg px-3 py-2 text-sm";
|
|
17
|
+
|
|
18
|
+
function ago(v) {
|
|
19
|
+
var ts = typeof v === "number" ? v : v ? Date.parse(v) : NaN;
|
|
20
|
+
if (!isFinite(ts)) return "";
|
|
21
|
+
var s = Math.max(0, (Date.now() - ts) / 1000);
|
|
22
|
+
if (s < 90) return Math.round(s) + "s ago";
|
|
23
|
+
if (s < 5400) return Math.round(s / 60) + "m ago";
|
|
24
|
+
if (s < 129600) return Math.round(s / 3600) + "h ago";
|
|
25
|
+
return Math.round(s / 86400) + "d ago";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function Row({ label, children }) {
|
|
29
|
+
return h("div", { className: "flex items-baseline justify-between gap-6 text-sm" },
|
|
30
|
+
h("span", { className: "text-muted shrink-0 text-xs uppercase tracking-wider" }, label),
|
|
31
|
+
h("span", { className: "min-w-0 break-all text-right font-mono text-xs" }, children));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function MiniTable({ title, cols, rows, empty, rowAction }) {
|
|
35
|
+
return h(GlassPanel, { className: "p-6 space-y-3" },
|
|
36
|
+
h("h3", { className: "font-semibold" }, title),
|
|
37
|
+
rows.length === 0
|
|
38
|
+
? h("p", { className: "text-sm text-muted" }, empty)
|
|
39
|
+
: h("div", { className: "overflow-x-auto" }, h("table", { className: "w-full text-sm" },
|
|
40
|
+
h("thead", null, h("tr", { className: "text-left text-xs text-muted" },
|
|
41
|
+
cols.map((c) => h("th", { key: c.key, className: "py-1 pr-4 font-normal" }, c.label)),
|
|
42
|
+
rowAction ? h("th", null) : null)),
|
|
43
|
+
h("tbody", null, rows.map((r, i) =>
|
|
44
|
+
h("tr", { key: i, className: "border-t border-white/5" },
|
|
45
|
+
cols.map((c) => h("td", { key: c.key, className: "py-1.5 pr-4 text-xs " + (c.mono ? "font-mono" : "") },
|
|
46
|
+
c.render ? c.render(r) : String(r[c.key] == null ? "—" : r[c.key]))),
|
|
47
|
+
rowAction ? h("td", { className: "py-1.5 text-right" },
|
|
48
|
+
h("button", { className: "text-xs text-muted hover:text-[var(--color-neon-pink)]", onClick: () => rowAction.run(r) }, rowAction.label)) : null))))));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Roles editor: the configured map as checkboxes + free-form extras ────
|
|
52
|
+
function RolesEditor({ user, onSaved }) {
|
|
53
|
+
const known = user.knownRoles || [];
|
|
54
|
+
const [roles, setRoles] = React.useState(user.rolesList || []);
|
|
55
|
+
const [extra, setExtra] = React.useState("");
|
|
56
|
+
const [confirming, setConfirming] = React.useState(false);
|
|
57
|
+
const [busy, setBusy] = React.useState(false);
|
|
58
|
+
const toggle = (r) => setRoles(roles.indexOf(r) >= 0 ? roles.filter((x) => x !== r) : roles.concat(r));
|
|
59
|
+
const finalRoles = () => roles.concat(extra.split(",").map((s) => s.trim()).filter((s) => s && roles.indexOf(s) < 0));
|
|
60
|
+
const changed = JSON.stringify(finalRoles().slice().sort()) !== JSON.stringify((user.rolesList || []).slice().sort());
|
|
61
|
+
function save() {
|
|
62
|
+
setBusy(true);
|
|
63
|
+
api.call("POST", "/identity/users/" + encodeURIComponent(user["user id"]) + "/roles", { roles: finalRoles().join(",") })
|
|
64
|
+
.then(() => { setConfirming(false); setExtra(""); onSaved(); })
|
|
65
|
+
.finally(() => setBusy(false));
|
|
66
|
+
}
|
|
67
|
+
return h(GlassPanel, { className: "p-6 space-y-3" },
|
|
68
|
+
h("h3", { className: "font-semibold" }, "Roles"),
|
|
69
|
+
h("p", { className: "text-xs text-muted" },
|
|
70
|
+
"Roles become scopes on the next request (the identity roles map). Saving REPLACES the set and signs the user out everywhere."),
|
|
71
|
+
h("div", { className: "space-y-1.5" }, known.map((r) =>
|
|
72
|
+
h("label", { key: r, className: "flex cursor-pointer items-center gap-2 text-sm" },
|
|
73
|
+
h("input", { type: "checkbox", checked: roles.indexOf(r) >= 0, onChange: () => toggle(r) }),
|
|
74
|
+
h("span", { className: "font-mono text-xs" }, r))),
|
|
75
|
+
known.length === 0 && h("p", { className: "text-xs text-muted" }, "No roles map configured — add roles free-form below.")),
|
|
76
|
+
h("input", { className: inputCls, value: extra, placeholder: "extra roles, comma-separated (optional)", onChange: (e) => setExtra(e.target.value) }),
|
|
77
|
+
h(NeonButton, { disabled: !changed || busy, onClick: () => setConfirming(true) }, "Save roles"),
|
|
78
|
+
h(Modal, { open: confirming, onClose: () => setConfirming(false), title: "Replace roles?" },
|
|
79
|
+
h("div", { className: "space-y-4" },
|
|
80
|
+
h("p", { className: "text-sm" },
|
|
81
|
+
"New set: ", h("span", { className: "font-mono text-xs" }, finalRoles().join(", ") || "(none — plain user)"),
|
|
82
|
+
". This is a privilege change: the user is signed out of every session."),
|
|
83
|
+
h("div", { className: "flex justify-end gap-2" },
|
|
84
|
+
h(NeonButton, { variant: "ghost", onClick: () => setConfirming(false) }, "Cancel"),
|
|
85
|
+
h(NeonButton, { variant: "danger", disabled: busy, onClick: save }, busy ? "Saving…" : "Replace roles")))));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── The action strip: mint link / disable / log out / delete ─────────────
|
|
89
|
+
function Actions({ user, reload }) {
|
|
90
|
+
const id = encodeURIComponent(user["user id"]);
|
|
91
|
+
const [confirm, setConfirm] = React.useState(null); // { label, run, note }
|
|
92
|
+
const [minted, setMinted] = React.useState(null);
|
|
93
|
+
const [busy, setBusy] = React.useState(false);
|
|
94
|
+
const act = (method, path) => api.call(method, path).then(reload);
|
|
95
|
+
const buttons = [
|
|
96
|
+
{ label: "Sign-in link", run: () => api.call("POST", "/identity/users/" + id + "/login-link").then((r) => setMinted((r && (r.result || r.link || r.copy)) || r)) },
|
|
97
|
+
{ label: user.disabled ? "Enable" : "Disable", confirm: user.disabled ? "Re-enable this account?" : "The user can't sign in (existing sessions end).", run: () => act("POST", "/identity/users/" + id + "/toggle-disabled") },
|
|
98
|
+
{ label: "Log out everywhere", confirm: "Ends every active session for this user.", run: () => act("POST", "/identity/users/" + id + "/revoke-sessions") },
|
|
99
|
+
{ label: "Delete", confirm: "Deletes the account permanently.", danger: true, run: () => api.call("DELETE", "/identity/users/" + id).then(() => history.back()) },
|
|
100
|
+
];
|
|
101
|
+
const fire = (b) => (b.confirm ? setConfirm(b) : b.run());
|
|
102
|
+
const mintedText = minted && (typeof minted === "string" ? minted : (minted.copy || minted.link || JSON.stringify(minted)));
|
|
103
|
+
return h("div", { className: "space-y-3" },
|
|
104
|
+
h("div", { className: "flex flex-wrap gap-2" },
|
|
105
|
+
buttons.map((b) => h(NeonButton, { key: b.label, variant: b.danger ? "danger" : "ghost", disabled: busy, onClick: () => fire(b) }, b.label))),
|
|
106
|
+
mintedText && h("div", { className: "flex items-center gap-2" },
|
|
107
|
+
h("input", { readOnly: true, value: String(mintedText).startsWith("/") ? location.origin + mintedText : mintedText,
|
|
108
|
+
onFocus: (e) => e.currentTarget.select(), className: "glass min-w-0 flex-1 rounded-lg px-3 py-2 font-mono text-xs" }),
|
|
109
|
+
h(NeonButton, { onClick: () => { navigator.clipboard && navigator.clipboard.writeText(String(mintedText).startsWith("/") ? location.origin + mintedText : mintedText); } }, "Copy")),
|
|
110
|
+
h(Modal, { open: confirm !== null, onClose: () => setConfirm(null), title: confirm ? confirm.label : "" },
|
|
111
|
+
confirm && h("div", { className: "space-y-4" },
|
|
112
|
+
h("p", { className: "text-sm" }, confirm.confirm),
|
|
113
|
+
h("div", { className: "flex justify-end gap-2" },
|
|
114
|
+
h(NeonButton, { variant: "ghost", onClick: () => setConfirm(null) }, "Cancel"),
|
|
115
|
+
h(NeonButton, { variant: "danger", disabled: busy, onClick: () => { setBusy(true); Promise.resolve(confirm.run()).finally(() => { setBusy(false); setConfirm(null); }); } }, confirm.label)))));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Billing, duck-typed: present ⇒ a panel; absent ⇒ nothing ────────────
|
|
119
|
+
function BillingPanel({ userId }) {
|
|
120
|
+
const [state, setState] = React.useState({ probed: false, row: null });
|
|
121
|
+
React.useEffect(() => {
|
|
122
|
+
api.call("GET", "/billing/api/customers")
|
|
123
|
+
.then((r) => {
|
|
124
|
+
const rows = Array.isArray(r) ? r : (r && r.customers) || [];
|
|
125
|
+
setState({ probed: true, row: rows.find((c) => c.userId === userId) || null });
|
|
126
|
+
})
|
|
127
|
+
.catch(() => setState({ probed: false, row: null }));
|
|
128
|
+
}, [userId]);
|
|
129
|
+
if (!state.probed) return null;
|
|
130
|
+
return h(GlassPanel, { className: "p-6 space-y-3" },
|
|
131
|
+
h("h3", { className: "font-semibold" }, "Billing"),
|
|
132
|
+
state.row
|
|
133
|
+
? h("div", { className: "space-y-2" },
|
|
134
|
+
h(Row, { label: "Status" }, h(Badge, null, String(state.row.status || "—"))),
|
|
135
|
+
h(Row, { label: "Entitled" }, state.row.entitled === true || state.row.entitled === "yes" ? "yes" : "no"),
|
|
136
|
+
h(Row, { label: "Plan" }, String(state.row.priceKeys || "—")),
|
|
137
|
+
h(Row, { label: "Owns" }, String(state.row.purchased || "—")),
|
|
138
|
+
h(Row, { label: "Customer" }, String(state.row.customerId || "—")),
|
|
139
|
+
h(Row, { label: "Updated" }, ago(state.row.updatedAt) || "—"))
|
|
140
|
+
: h("p", { className: "text-sm text-muted" }, "Nothing yet — this user has never completed a checkout."));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export default function UserPage({ params }) {
|
|
144
|
+
const userId = (params && params.userId) || decodeURIComponent(location.pathname.split("/").pop() || "");
|
|
145
|
+
const [user, setUser] = React.useState(null);
|
|
146
|
+
const [sessions, setSessions] = React.useState([]);
|
|
147
|
+
const [stats, setStats] = React.useState([]);
|
|
148
|
+
const [error, setError] = React.useState(false);
|
|
149
|
+
const id = encodeURIComponent(userId);
|
|
150
|
+
|
|
151
|
+
const reload = () => Promise.all([
|
|
152
|
+
api.call("GET", "/identity/users/" + id).then((r) => setUser((r && r.user) || r)),
|
|
153
|
+
api.call("GET", "/identity/users/" + id + "/sessions").then((r) => setSessions(Array.isArray(r) ? r : (r && r.sessions) || [])),
|
|
154
|
+
api.call("GET", "/identity/users/" + id + "/run-stats").then((r) => setStats(Array.isArray(r) ? r : (r && r.stats) || [])).catch(() => {}),
|
|
155
|
+
]).catch(() => setError(true));
|
|
156
|
+
React.useEffect(() => { reload(); }, [userId]);
|
|
157
|
+
|
|
158
|
+
if (error) return h(GlassPanel, { className: "p-6 text-sm", style: { color: "var(--color-neon-pink)" } }, "Couldn't load this user.");
|
|
159
|
+
if (!user) return null;
|
|
160
|
+
|
|
161
|
+
const revokeSession = { label: "Revoke", run: (r) => api.call("DELETE", "/identity/sessions/" + encodeURIComponent(r.id)).then(reload) };
|
|
162
|
+
|
|
163
|
+
return h("div", { className: "grid gap-6 lg:grid-cols-2 items-start" },
|
|
164
|
+
h("div", { className: "space-y-6" },
|
|
165
|
+
h(GlassPanel, { className: "p-6 space-y-3" },
|
|
166
|
+
h("div", { className: "flex items-center justify-between gap-3" },
|
|
167
|
+
h("div", null,
|
|
168
|
+
h("h3", { className: "font-semibold" }, String(user.email || userId)),
|
|
169
|
+
h("p", { className: "text-xs text-muted mt-0.5" }, String(user.name || ""))),
|
|
170
|
+
user.disabled ? h(Badge, { hue: 340 }, "disabled") : h(Badge, { hue: 140 }, "active")),
|
|
171
|
+
h("div", { className: "space-y-2" },
|
|
172
|
+
h(Row, { label: "Roles" }, String(user.roles || "—")),
|
|
173
|
+
h(Row, { label: "Scopes" }, String(user.scopes || "—")),
|
|
174
|
+
h(Row, { label: "Sessions" }, String(user["active sessions"])),
|
|
175
|
+
h(Row, { label: "Created" }, ago(user.createdAt) || String(user.created || "")),
|
|
176
|
+
h(Row, { label: "Id" }, String(user["user id"]))),
|
|
177
|
+
h(Actions, { user, reload })),
|
|
178
|
+
h(RolesEditor, { key: String(user.roles), user, onSaved: reload })),
|
|
179
|
+
h("div", { className: "space-y-6" },
|
|
180
|
+
h(BillingPanel, { userId: user["user id"] }),
|
|
181
|
+
h(MiniTable, {
|
|
182
|
+
title: "Sessions", empty: "No sessions.",
|
|
183
|
+
cols: [
|
|
184
|
+
{ key: "status", label: "Status", render: (r) => h(Badge, null, String(r.status || "—")) },
|
|
185
|
+
{ key: "created", label: "Created", render: (r) => ago(r.created) || "—" },
|
|
186
|
+
{ key: "lastSeen", label: "Last seen", render: (r) => ago(r.lastSeen) || "—" },
|
|
187
|
+
{ key: "userAgent", label: "Device" },
|
|
188
|
+
],
|
|
189
|
+
rows: sessions, rowAction: revokeSession,
|
|
190
|
+
}),
|
|
191
|
+
h(MiniTable, {
|
|
192
|
+
title: "Runs by workflow (recent window)", empty: "No runs in the retained window.",
|
|
193
|
+
cols: [
|
|
194
|
+
{ key: "workflow", label: "Workflow", mono: true },
|
|
195
|
+
{ key: "runs", label: "Runs" },
|
|
196
|
+
{ key: "errors", label: "Errors" },
|
|
197
|
+
{ key: "avg ms", label: "Avg ms" },
|
|
198
|
+
{ key: "last run", label: "Last run" },
|
|
199
|
+
],
|
|
200
|
+
rows: stats,
|
|
201
|
+
})));
|
|
202
|
+
}
|
|
203
|
+
`;
|
|
204
|
+
//# sourceMappingURL=user-page.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"user-page.js","sourceRoot":"","sources":["../src/user-page.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,MAAM,CAAC,MAAM,gBAAgB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+L/B,CAAC"}
|
package/dist/workflows.d.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* (admin-stamped invoke), not routes.
|
|
10
10
|
*/
|
|
11
11
|
import type { Workflow } from "@pattern-js/core";
|
|
12
|
+
import type { RateBudget } from "./options.js";
|
|
12
13
|
/** The identity routes under `mount` (default /auth). */
|
|
13
|
-
export declare function endpointWorkflows(mount: string): Workflow[];
|
|
14
|
+
export declare function endpointWorkflows(mount: string, perClient?: RateBudget | false): Workflow[];
|
|
14
15
|
//# sourceMappingURL=workflows.d.ts.map
|
package/dist/workflows.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"workflows.d.ts","sourceRoot":"","sources":["../src/workflows.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"workflows.d.ts","sourceRoot":"","sources":["../src/workflows.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAiG/C,yDAAyD;AACzD,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,GAAE,UAAU,GAAG,KAAkC,GAAG,QAAQ,EAAE,CAavH"}
|
package/dist/workflows.js
CHANGED
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
* (admin-stamped invoke), not routes.
|
|
10
10
|
*/
|
|
11
11
|
import { authOpRoutes } from "./ops.js";
|
|
12
|
+
/** The trigger config: method + path, plus the per-IP budget when the route has one. */
|
|
13
|
+
function triggerConfig(spec) {
|
|
14
|
+
return {
|
|
15
|
+
method: spec.method,
|
|
16
|
+
path: spec.path,
|
|
17
|
+
...(spec.rateLimit ? { rateLimit: { max: spec.rateLimit.max, window: spec.rateLimit.window, by: "ip" } } : {}),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
12
20
|
function endpoint(spec) {
|
|
13
21
|
if (spec.shape === "http") {
|
|
14
22
|
// Auth pages + redirects: decompose the request into the PURE op's discrete
|
|
@@ -19,7 +27,7 @@ function endpoint(spec) {
|
|
|
19
27
|
if (!io)
|
|
20
28
|
throw new Error(`identity route "${spec.id}": no auth I/O for op "${spec.op}"`);
|
|
21
29
|
const nodes = [
|
|
22
|
-
{ id: "in", op: "boundary.http.request", config:
|
|
30
|
+
{ id: "in", op: "boundary.http.request", config: triggerConfig(spec) },
|
|
23
31
|
{ id: "call", op: spec.op },
|
|
24
32
|
{ id: "ct", op: "core.const.object", config: { value: { "content-type": "text/html; charset=utf-8" } } },
|
|
25
33
|
{ id: "out", op: "boundary.http.response", config: { mode: "buffered" } },
|
|
@@ -60,7 +68,7 @@ function endpoint(spec) {
|
|
|
60
68
|
const out = spec.out ?? "out";
|
|
61
69
|
const query = spec.query ?? [];
|
|
62
70
|
const nodes = [
|
|
63
|
-
{ id: "in", op: "boundary.http.request", config:
|
|
71
|
+
{ id: "in", op: "boundary.http.request", config: triggerConfig(spec) },
|
|
64
72
|
{ id: "call", op: spec.op },
|
|
65
73
|
{ id: "status", op: "boundary.http.status" },
|
|
66
74
|
{ id: "out", op: "boundary.http.response", config: { mode: "buffered" } },
|
|
@@ -81,16 +89,17 @@ function endpoint(spec) {
|
|
|
81
89
|
return { id: spec.id, name: `Identity · ${spec.method} ${spec.path}`, source: "code", nodes, edges };
|
|
82
90
|
}
|
|
83
91
|
/** The identity routes under `mount` (default /auth). */
|
|
84
|
-
export function endpointWorkflows(mount) {
|
|
92
|
+
export function endpointWorkflows(mount, perClient = { max: 20, window: "15m" }) {
|
|
85
93
|
const specs = [
|
|
86
94
|
{ id: "identity.route.login", method: "GET", path: `${mount}/login`, op: "identity.login.page", shape: "http" },
|
|
87
95
|
{ id: "identity.route.token", method: "GET", path: `${mount}/token`, op: "identity.token.callback", shape: "http" },
|
|
96
|
+
{ id: "identity.route.code", method: "POST", path: `${mount}/code`, op: "identity.code.callback", shape: "http", rateLimit: perClient },
|
|
88
97
|
{ id: "identity.route.logout", method: "POST", path: `${mount}/logout`, op: "identity.logout", shape: "http" },
|
|
89
98
|
{ id: "identity.route.whoami", method: "GET", path: `${mount}/whoami`, op: "identity.whoami", shape: "json", out: "whoami" },
|
|
90
99
|
{ id: "identity.route.welcome", method: "GET", path: `${mount}/welcome`, op: "identity.welcome.page", shape: "http" },
|
|
91
100
|
{ id: "identity.route.invited", method: "GET", path: `${mount}/invited`, op: "identity.invited.page", shape: "http" },
|
|
92
101
|
{ id: "identity.route.bootstrap", method: "GET", path: `${mount}/bootstrap`, op: "identity.bootstrap.page", shape: "http" },
|
|
93
|
-
{ id: "identity.route.bootstrap.submit", method: "POST", path: `${mount}/bootstrap`, op: "identity.bootstrap.submit", shape: "http" },
|
|
102
|
+
{ id: "identity.route.bootstrap.submit", method: "POST", path: `${mount}/bootstrap`, op: "identity.bootstrap.submit", shape: "http", rateLimit: perClient },
|
|
94
103
|
];
|
|
95
104
|
return specs.map(endpoint);
|
|
96
105
|
}
|
package/dist/workflows.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"workflows.js","sourceRoot":"","sources":["../src/workflows.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;
|
|
1
|
+
{"version":3,"file":"workflows.js","sourceRoot":"","sources":["../src/workflows.ts"],"names":[],"mappings":"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"}
|
package/docs/index.md
CHANGED
|
@@ -40,8 +40,19 @@ Now a user with role `editor` resolves to scopes `["edit","read"]`; gate a
|
|
|
40
40
|
route with `requireAuth: { scopes: ["edit"] }` and both `admin` (if you also
|
|
41
41
|
grant it `edit`) and `editor` users get through, `viewer` does not. The same
|
|
42
42
|
roles surface on the admin Users screen and are settable there
|
|
43
|
-
(`identity.users.setRoles
|
|
44
|
-
|
|
43
|
+
(`identity.users.setRoles`). Scopes resolve live from the user's roles on
|
|
44
|
+
every request, so a **gain** reaches the user's open sessions immediately;
|
|
45
|
+
a **loss** (any role or scope removed) and an elevation to `admin` end the
|
|
46
|
+
user's sessions instead.
|
|
47
|
+
|
|
48
|
+
**Entitlements from billing (0.5).** Because scopes recompile from roles on
|
|
49
|
+
every request, a paid plan can *be* a scope: `@pattern-js/mod-billing` projects
|
|
50
|
+
subscription status into a role (say `member`) as verified webhooks arrive, and
|
|
51
|
+
your map turns it into scopes (`{ member: ["pro"] }`) — so
|
|
52
|
+
`requireAuth: { scopes: ["pro"] }` gates a route behind an active plan with no
|
|
53
|
+
new enforcement code. The projection only writes on actual entitlement
|
|
54
|
+
*transitions* (a renewal never logs anyone out). See the **Billing** chapter in
|
|
55
|
+
the handbook — the `saas-starter` scaffold wires the whole bridge.
|
|
45
56
|
|
|
46
57
|
## Bootstrap on first boot
|
|
47
58
|
|
|
@@ -63,8 +74,8 @@ no special-case admin password anywhere.
|
|
|
63
74
|
Opaque 256-bit secrets in an `HttpOnly; SameSite=Lax` cookie (`pattern_session`
|
|
64
75
|
by default); only the sha256 is stored. Sliding 30-day expiry with throttled
|
|
65
76
|
touches. Sessions are **revocable**: individually (`identity.sessions.revoke`),
|
|
66
|
-
per user (`identity.users.revokeSessions`), and automatically when a
|
|
67
|
-
|
|
77
|
+
per user (`identity.users.revokeSessions`), and automatically when a user
|
|
78
|
+
loses a role or scope, is elevated to `admin`, or is disabled. Set `cookieSecure: true` behind TLS in
|
|
68
79
|
production. CSRF protection lives *inside* the session provider: a cross-site
|
|
69
80
|
state-changing request doesn't authenticate (so it 401s with no token
|
|
70
81
|
machinery), while GETs always pass; the magic-link callback is a cross-site
|
|
@@ -117,11 +128,12 @@ rooms:
|
|
|
117
128
|
Magic links, invites, and the bootstrap link all print to the **server
|
|
118
129
|
console** until delivery is wired. The packaged way: install
|
|
119
130
|
`@pattern-js/mod-email` + a driver (Resend or SMTP) and create the `default`
|
|
120
|
-
account in admin →
|
|
131
|
+
account in admin → Resources → Email — links then send by email automatically.
|
|
121
132
|
Any other channel works through the `identity.deliverToken` hook
|
|
122
|
-
(`payload: { email, url, purpose, delivered }`): send
|
|
123
|
-
and return `delivered: true`. No subscriber (or
|
|
124
|
-
to the console, which **is** the zero-config dev
|
|
133
|
+
(`payload: { email, url, purpose, delivered, subject, message, code? }`): send
|
|
134
|
+
the link by email/SMS/chat and return `delivered: true`. No subscriber (or
|
|
135
|
+
`delivered: false`) falls back to the console, which **is** the zero-config dev
|
|
136
|
+
login. Tokens are single-use, short-TTL (15 min; invites 7 days), sha256 at
|
|
125
137
|
rest, consumed via CAS so replays fail closed. Purposes: `login`, `invite`
|
|
126
138
|
(carries roles), `bootstrap`. The `/auth/token` callback turns a consumed token
|
|
127
139
|
into a user + session per the **signup policy**: `invite` (default; unknown
|
|
@@ -135,9 +147,60 @@ proxy or tunnel the Host header is whatever the hop put there. Unset (dev),
|
|
|
135
147
|
the request's own origin is used, so localhost links still work with zero
|
|
136
148
|
config.
|
|
137
149
|
|
|
150
|
+
## Sign-in codes (0.5)
|
|
151
|
+
|
|
152
|
+
A login token issued with `code: true` (magic-link does this) also carries a
|
|
153
|
+
**6-digit code** bound to the same row — same TTL, same single use. It exists
|
|
154
|
+
for **installed PWAs and standalone web apps**: the emailed link opens in the
|
|
155
|
+
*system browser's* cookie jar, so the session lands in Safari/Chrome while the
|
|
156
|
+
installed app stays signed out. The code closes that gap — the "check your
|
|
157
|
+
inbox" page carries a code form, and `POST /auth/code` `{ email, code, next }`
|
|
158
|
+
consumes the token and sets the cookie **in the browsing context that asked**.
|
|
159
|
+
Whichever of link or code lands first burns both.
|
|
160
|
+
|
|
161
|
+
A short code can't lean on hash lookup the way 256-bit tokens do, so its
|
|
162
|
+
safety is budgeted instead: verification compares in constant time against
|
|
163
|
+
the email's pending code-bearing tokens, **five wrong guesses burn the token**
|
|
164
|
+
(link included), and failures render identically for unknown emails and wrong
|
|
165
|
+
codes (status 401, no enumeration). The code travels to custom templates as
|
|
166
|
+
`code` on the `identity.deliverToken` payload, is worked into the default
|
|
167
|
+
`message` copy, and prints with the console fallback.
|
|
168
|
+
|
|
169
|
+
## Rate limits (0.5)
|
|
170
|
+
|
|
171
|
+
The sign-in surface is public, and every request to it can cost an email. Two
|
|
172
|
+
budgets are on by default:
|
|
173
|
+
|
|
174
|
+
- **Per address**: at most 5 sign-in emails per address per 15 minutes,
|
|
175
|
+
counted in the identity store, so a restart or a second instance doesn't
|
|
176
|
+
reset it. Past it, the request gets the usual "check your email" page and
|
|
177
|
+
nothing is sent, so the budget reveals nothing about the address. Login
|
|
178
|
+
providers ask `svc.signInEmailAllowed(email)` before issuing (magic-link
|
|
179
|
+
does).
|
|
180
|
+
- **Per client IP**: 20 requests per 15 minutes each to `POST {mount}/code`
|
|
181
|
+
and `POST {mount}/bootstrap`, answered `429` past it (the route's
|
|
182
|
+
`rateLimit`). Together with the five-guess burn, this keeps code guessing
|
|
183
|
+
far from useful. Behind a proxy, set `PATTERN_TRUST_PROXY` so the host sees
|
|
184
|
+
client addresses.
|
|
185
|
+
|
|
186
|
+
Tune or turn them off in a wrapper mod:
|
|
187
|
+
|
|
188
|
+
```js
|
|
189
|
+
identityMod({
|
|
190
|
+
rateLimit: {
|
|
191
|
+
perAddress: { max: 5, window: "15m" }, // false = off
|
|
192
|
+
perClient: { max: 20, window: "15m" }, // false = off
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The magic-link form has its own per-IP budget (`magicLinkMod({ rateLimit })`).
|
|
198
|
+
The same bricks are open to your own routes: `rateLimit` on any
|
|
199
|
+
`boundary.http.request`, and `core.flow.throttle` inside a workflow.
|
|
200
|
+
|
|
138
201
|
## Invites (0.4)
|
|
139
202
|
|
|
140
|
-
An invite is a **record**, not just a token: admin → **
|
|
203
|
+
An invite is a **record**, not just a token: admin → **Administration → Invites**
|
|
141
204
|
sends one (email, roles, and an optional **next path** — where the invitee's
|
|
142
205
|
first login lands, e.g. `/admin` or `/chat`) and lists every invite sent with
|
|
143
206
|
its derived status: `pending` → `accepted` (or `expired` / `revoked`). Revoke
|
|
@@ -153,7 +216,7 @@ method.
|
|
|
153
216
|
|
|
154
217
|
## Administering users (0.4)
|
|
155
218
|
|
|
156
|
-
|
|
219
|
+
Administration → **Users** is the control room: per row — details, a minted sign-in
|
|
157
220
|
link, **disable / enable** (reversible lock-out; revokes sessions), **log out
|
|
158
221
|
everywhere**, and **delete** (removes the user, their identity links and
|
|
159
222
|
session rows; invites and API tokens keep the id as audit trail). Roles are
|
|
@@ -167,7 +230,7 @@ it.
|
|
|
167
230
|
|
|
168
231
|
Sessions authenticate humans in browsers; **API tokens** authenticate
|
|
169
232
|
programs — MCP clients on `/mcp/pattern`, CI deploys, scripts against the
|
|
170
|
-
admin API. Mint them in admin → **
|
|
233
|
+
admin API. Mint them in admin → **Administration → API tokens**: the raw `pat_…`
|
|
171
234
|
secret is shown **exactly once** (only its sha256 is stored), and a bearer
|
|
172
235
|
header authenticates it on any route:
|
|
173
236
|
|
|
@@ -220,6 +283,7 @@ workflow storage.
|
|
|
220
283
|
| --- | --- |
|
|
221
284
|
| `GET /auth/login` | Login page (a section per registered method) |
|
|
222
285
|
| `GET /auth/token?t=…` | Token callback → session cookie → redirect `next` |
|
|
286
|
+
| `POST /auth/code` | Sign-in code callback `{ email, code, next? }` → cookie **here** (the PWA path) |
|
|
223
287
|
| `POST /auth/logout` | Revoke the current session, clear the cookie |
|
|
224
288
|
| `GET /auth/whoami` | The current principal (JSON) |
|
|
225
289
|
| `GET /auth/welcome` | Post-login landing when no home is advertised |
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
The sign-in **code** callback: verify a `{ email, code }` pair against the
|
|
2
|
+
email's pending code-bearing login tokens, consume the matched token, mint a
|
|
3
|
+
session, and redirect to `next` with the cookie set — **in the browsing
|
|
4
|
+
context that posted**. An HTTP-shaped op backing `POST /auth/code`.
|
|
5
|
+
|
|
6
|
+
This is the installed-PWA path. The emailed magic *link* opens in the system
|
|
7
|
+
browser — a different cookie jar than a standalone-display app — so the link
|
|
8
|
+
signs in the wrong context. The code, typed into the "check your inbox" page
|
|
9
|
+
the user already has open, completes the login exactly where they are. The
|
|
10
|
+
link and the code share one single-use token: whichever lands first burns
|
|
11
|
+
both.
|
|
12
|
+
|
|
13
|
+
Failure semantics are deliberate: wrong code, unknown email, expired or
|
|
14
|
+
burned token all render the same sent page with the same error and status 401
|
|
15
|
+
(no account enumeration); comparisons are constant-time; and five wrong
|
|
16
|
+
guesses burn the token, link included. Only a **correct** code reaches the
|
|
17
|
+
signup-policy / disabled checks — possessing it proves control of the inbox.
|
|
@@ -4,4 +4,6 @@ and redirect to `next` with the cookie set. An HTTP-shaped op backing
|
|
|
4
4
|
`GET /auth/token`. Provider mods (magic-link, OIDC) issue tokens whose links
|
|
5
5
|
land here; they never mint sessions themselves. Bootstrap tokens are refused
|
|
6
6
|
here (they have their own flow), and with a closed signup an unknown email
|
|
7
|
-
returns to the login page with an error.
|
|
7
|
+
returns to the login page with an error. The same token may also carry a
|
|
8
|
+
short sign-in code consumed by `identity.code.callback` (`POST /auth/code`) —
|
|
9
|
+
one row, one use, whichever path lands first.
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
Replace a user's roles (the WHOLE set, not a patch)
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
Replace a user's roles (the WHOLE set, not a patch). Scopes resolve live from
|
|
2
|
+
the role→scopes map on every request, so a pure **gain** (a paid plan's role,
|
|
3
|
+
an editor role) takes effect on the user's next request with their sessions
|
|
4
|
+
intact. A **loss** (any role or scope removed) and an elevation to `admin`
|
|
5
|
+
**revoke all their sessions**, which forces a re-login. Refused when it would demote the last
|
|
5
6
|
active admin — an app must always keep someone who can administer it. `roles`
|
|
6
7
|
accepts an array or a comma string; privileged: gate the trigger with the
|
|
7
8
|
`admin` scope. The admin UI edits this from the user's details page.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pattern-js/mod-identity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Users, sessions, roles and pluggable login methods for Pattern — the optional identity brick. Opaque revocable cookie sessions (CAS-backed, sqlite or in-memory), a single-use token kernel (login/invite/bootstrap), a login page, and admin screens.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pattern",
|
|
@@ -26,14 +26,14 @@
|
|
|
26
26
|
"README.md"
|
|
27
27
|
],
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"zod": "^4.
|
|
30
|
-
"@pattern-js/
|
|
31
|
-
"@pattern-js/
|
|
29
|
+
"zod": "^4.6.7",
|
|
30
|
+
"@pattern-js/core": "0.5.0",
|
|
31
|
+
"@pattern-js/runtime-node": "0.5.0"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@types/node": "^22.0.0",
|
|
35
35
|
"typescript": "^5.6.0",
|
|
36
|
-
"@pattern-js/runtime-node": "0.
|
|
36
|
+
"@pattern-js/runtime-node": "0.5.0"
|
|
37
37
|
},
|
|
38
38
|
"engines": {
|
|
39
39
|
"node": ">=22.5"
|