@asturiu/v0-sdk 0.0.0-stage → 1.0.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/CHANGELOG.md +42 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +82 -2
- package/contract/assertion-vectors.json +90 -0
- package/contract/schemas/branding.schema.json +46 -0
- package/contract/schemas/definitions.schema.json +376 -0
- package/contract/schemas/portal.schema.json +56 -0
- package/contract/tenant-api-routes.json +165 -0
- package/dist/branding.d.ts +131 -0
- package/dist/branding.d.ts.map +1 -0
- package/dist/branding.js +206 -0
- package/dist/branding.js.map +1 -0
- package/dist/changesets.d.ts +58 -0
- package/dist/changesets.d.ts.map +1 -0
- package/dist/changesets.js +18 -0
- package/dist/changesets.js.map +1 -0
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +12 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/changesets.d.ts +14 -0
- package/dist/cli/changesets.d.ts.map +1 -0
- package/dist/cli/changesets.js +252 -0
- package/dist/cli/changesets.js.map +1 -0
- package/dist/cli/cli.d.ts +37 -0
- package/dist/cli/cli.d.ts.map +1 -0
- package/dist/cli/cli.js +415 -0
- package/dist/cli/cli.js.map +1 -0
- package/dist/cli/context.d.ts +147 -0
- package/dist/cli/context.d.ts.map +1 -0
- package/dist/cli/context.js +258 -0
- package/dist/cli/context.js.map +1 -0
- package/dist/cli/engine.d.ts +59 -0
- package/dist/cli/engine.d.ts.map +1 -0
- package/dist/cli/engine.js +85 -0
- package/dist/cli/engine.js.map +1 -0
- package/dist/cli/feedback.d.ts +40 -0
- package/dist/cli/feedback.d.ts.map +1 -0
- package/dist/cli/feedback.js +420 -0
- package/dist/cli/feedback.js.map +1 -0
- package/dist/cli/files.d.ts +83 -0
- package/dist/cli/files.d.ts.map +1 -0
- package/dist/cli/files.js +389 -0
- package/dist/cli/files.js.map +1 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +8 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/overview.d.ts +6 -0
- package/dist/cli/overview.d.ts.map +1 -0
- package/dist/cli/overview.js +379 -0
- package/dist/cli/overview.js.map +1 -0
- package/dist/cli/parts.d.ts +5 -0
- package/dist/cli/parts.d.ts.map +1 -0
- package/dist/cli/parts.js +414 -0
- package/dist/cli/parts.js.map +1 -0
- package/dist/cli/sources.d.ts +37 -0
- package/dist/cli/sources.d.ts.map +1 -0
- package/dist/cli/sources.js +91 -0
- package/dist/cli/sources.js.map +1 -0
- package/dist/cli/terminal.d.ts +135 -0
- package/dist/cli/terminal.d.ts.map +1 -0
- package/dist/cli/terminal.js +353 -0
- package/dist/cli/terminal.js.map +1 -0
- package/dist/cli/ui-page.d.ts +8 -0
- package/dist/cli/ui-page.d.ts.map +1 -0
- package/dist/cli/ui-page.js +216 -0
- package/dist/cli/ui-page.js.map +1 -0
- package/dist/cli/ui.d.ts +54 -0
- package/dist/cli/ui.d.ts.map +1 -0
- package/dist/cli/ui.js +251 -0
- package/dist/cli/ui.js.map +1 -0
- package/dist/client.d.ts +88 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +165 -0
- package/dist/client.js.map +1 -0
- package/dist/contacts.d.ts +43 -0
- package/dist/contacts.d.ts.map +1 -0
- package/dist/contacts.js +46 -0
- package/dist/contacts.js.map +1 -0
- package/dist/definition-set.d.ts +731 -0
- package/dist/definition-set.d.ts.map +1 -0
- package/dist/definition-set.js +659 -0
- package/dist/definition-set.js.map +1 -0
- package/dist/definitions.d.ts +42 -0
- package/dist/definitions.d.ts.map +1 -0
- package/dist/definitions.js +154 -0
- package/dist/definitions.js.map +1 -0
- package/dist/enrolment.d.ts +9 -0
- package/dist/enrolment.d.ts.map +1 -0
- package/dist/enrolment.js +8 -0
- package/dist/enrolment.js.map +1 -0
- package/dist/fill.d.ts +73 -0
- package/dist/fill.d.ts.map +1 -0
- package/dist/fill.js +46 -0
- package/dist/fill.js.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +39 -0
- package/dist/index.js.map +1 -0
- package/dist/notifications.d.ts +33 -0
- package/dist/notifications.d.ts.map +1 -0
- package/dist/notifications.js +36 -0
- package/dist/notifications.js.map +1 -0
- package/dist/oidc.d.ts +122 -0
- package/dist/oidc.d.ts.map +1 -0
- package/dist/oidc.js +150 -0
- package/dist/oidc.js.map +1 -0
- package/dist/overlay.d.ts +109 -0
- package/dist/overlay.d.ts.map +1 -0
- package/dist/overlay.js +58 -0
- package/dist/overlay.js.map +1 -0
- package/dist/passes.d.ts +95 -0
- package/dist/passes.d.ts.map +1 -0
- package/dist/passes.js +140 -0
- package/dist/passes.js.map +1 -0
- package/dist/portal-options.d.ts +65 -0
- package/dist/portal-options.d.ts.map +1 -0
- package/dist/portal-options.js +120 -0
- package/dist/portal-options.js.map +1 -0
- package/dist/reads.d.ts +41 -0
- package/dist/reads.d.ts.map +1 -0
- package/dist/reads.js +44 -0
- package/dist/reads.js.map +1 -0
- package/dist/recovery-words.d.ts +8 -0
- package/dist/recovery-words.d.ts.map +1 -0
- package/dist/recovery-words.js +136 -0
- package/dist/recovery-words.js.map +1 -0
- package/dist/recovery.d.ts +198 -0
- package/dist/recovery.d.ts.map +1 -0
- package/dist/recovery.js +112 -0
- package/dist/recovery.js.map +1 -0
- package/dist/reveal.d.ts +35 -0
- package/dist/reveal.d.ts.map +1 -0
- package/dist/reveal.js +66 -0
- package/dist/reveal.js.map +1 -0
- package/dist/seal.d.ts +15 -0
- package/dist/seal.d.ts.map +1 -0
- package/dist/seal.js +0 -0
- package/dist/seal.js.map +1 -0
- package/dist/session.d.ts +66 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +61 -0
- package/dist/session.js.map +1 -0
- package/dist/signing-in-mark.d.ts +14 -0
- package/dist/signing-in-mark.d.ts.map +1 -0
- package/dist/signing-in-mark.js +24 -0
- package/dist/signing-in-mark.js.map +1 -0
- package/dist/signing-in.d.ts +22 -0
- package/dist/signing-in.d.ts.map +1 -0
- package/dist/signing-in.js +203 -0
- package/dist/signing-in.js.map +1 -0
- package/dist/tags.d.ts +96 -0
- package/dist/tags.d.ts.map +1 -0
- package/dist/tags.js +88 -0
- package/dist/tags.js.map +1 -0
- package/dist/tenant-subscription.d.ts +47 -0
- package/dist/tenant-subscription.d.ts.map +1 -0
- package/dist/tenant-subscription.js +7 -0
- package/dist/tenant-subscription.js.map +1 -0
- package/dist/testing/index.d.ts +125 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +320 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/theme.d.ts +237 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +504 -0
- package/dist/theme.js.map +1 -0
- package/dist/tickets.d.ts +152 -0
- package/dist/tickets.d.ts.map +1 -0
- package/dist/tickets.js +65 -0
- package/dist/tickets.js.map +1 -0
- package/dist/tiers.d.ts +168 -0
- package/dist/tiers.d.ts.map +1 -0
- package/dist/tiers.js +341 -0
- package/dist/tiers.js.map +1 -0
- package/dist/types.d.ts +885 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -0
- package/dist/urlid.d.ts +50 -0
- package/dist/urlid.d.ts.map +1 -0
- package/dist/urlid.js +78 -0
- package/dist/urlid.js.map +1 -0
- package/dist/version.d.ts +15 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +15 -0
- package/dist/version.js.map +1 -0
- package/make/asturiu.mk +43 -0
- package/package.json +63 -4
- package/src/branding.ts +221 -0
- package/src/changesets.ts +61 -0
- package/src/cli/bin.ts +12 -0
- package/src/cli/changesets.ts +233 -0
- package/src/cli/cli.ts +425 -0
- package/src/cli/context.ts +299 -0
- package/src/cli/engine.ts +112 -0
- package/src/cli/feedback.ts +406 -0
- package/src/cli/files.ts +371 -0
- package/src/cli/index.ts +7 -0
- package/src/cli/overview.ts +343 -0
- package/src/cli/parts.ts +371 -0
- package/src/cli/sources.ts +111 -0
- package/src/cli/terminal.ts +384 -0
- package/src/cli/ui-page.ts +215 -0
- package/src/cli/ui.ts +266 -0
- package/src/client.ts +202 -0
- package/src/contacts.ts +61 -0
- package/src/definition-set.ts +965 -0
- package/src/definitions.ts +156 -0
- package/src/enrolment.ts +10 -0
- package/src/fill.ts +83 -0
- package/src/index.ts +38 -0
- package/src/notifications.ts +55 -0
- package/src/oidc.ts +199 -0
- package/src/overlay.ts +137 -0
- package/src/passes.ts +168 -0
- package/src/portal-options.ts +125 -0
- package/src/reads.ts +71 -0
- package/src/recovery-words.ts +137 -0
- package/src/recovery.ts +269 -0
- package/src/reveal.ts +72 -0
- package/src/seal.ts +0 -0
- package/src/session.ts +93 -0
- package/src/signing-in-mark.ts +23 -0
- package/src/signing-in.ts +213 -0
- package/src/tags.ts +146 -0
- package/src/tenant-subscription.ts +44 -0
- package/src/testing/index.ts +337 -0
- package/src/theme.ts +628 -0
- package/src/tickets.ts +195 -0
- package/src/tiers.ts +397 -0
- package/src/types.ts +847 -0
- package/src/urlid.ts +77 -0
- package/src/version.ts +15 -0
- package/templates/asturiu/branding.json +4 -0
- package/templates/asturiu/definitions.json +23 -0
- package/templates/asturiu/portal.json +16 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Definition-driven rendering helpers (components/02-customer-ui-web.md
|
|
3
|
+
* §Definition-driven rendering; ADR-005). Tenant-agnostic: a Frontend renders
|
|
4
|
+
* forms and views from the Definitions the Interface API serves, and a Branded
|
|
5
|
+
* Product only adds presentation on top.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { EntryDefinitionDetail, EntryDefinitionField, EntryKind, Field, FieldValueType, FolderNode } from "./types.js";
|
|
9
|
+
|
|
10
|
+
/** The kind of an EntryDefinition as served by `/v0/definitions/entries/{arid}`; Definitions written before ADR-014 carry none and are files. */
|
|
11
|
+
export function entryKind(def: Pick<EntryDefinitionDetail, "kind"> | { kind?: string } | null | undefined): EntryKind {
|
|
12
|
+
return def?.kind === "folder" ? "folder" : "file";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isFolderDefinition(def: Pick<EntryDefinitionDetail, "kind"> | { kind?: string } | null | undefined): boolean {
|
|
16
|
+
return entryKind(def) === "folder";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A folder tree flattened depth-first, each node with its depth and its trail of labels — what a "move to…" picker lists. */
|
|
20
|
+
export function flattenFolders(tree: FolderNode[], depth = 0, trail: string[] = []): { node: FolderNode; depth: number; trail: string[] }[] {
|
|
21
|
+
const out: { node: FolderNode; depth: number; trail: string[] }[] = [];
|
|
22
|
+
for (const node of tree) {
|
|
23
|
+
const here = [...trail, node.label ?? "Untitled folder"];
|
|
24
|
+
out.push({ node, depth, trail: here });
|
|
25
|
+
out.push(...flattenFolders(node.children, depth + 1, here));
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Whether `candidate` is `folder` itself or sits anywhere below it — the targets a folder must not be moved into. */
|
|
31
|
+
export function isWithinFolder(tree: FolderNode[], folder: string, candidate: string): boolean {
|
|
32
|
+
const find = (nodes: FolderNode[]): FolderNode | null => {
|
|
33
|
+
for (const n of nodes) {
|
|
34
|
+
if (n.arid === folder) return n;
|
|
35
|
+
const hit = find(n.children);
|
|
36
|
+
if (hit) return hit;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
};
|
|
40
|
+
const root = find(tree);
|
|
41
|
+
if (!root) return false;
|
|
42
|
+
const walk = (n: FolderNode): boolean => n.arid === candidate || n.children.some(walk);
|
|
43
|
+
return walk(root);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Declared Fields in display order (explicit `order`, then declaration order). */
|
|
47
|
+
export function fieldsInOrder(def: Pick<EntryDefinitionDetail, "fields">): EntryDefinitionField[] {
|
|
48
|
+
return def.fields.map((f, i) => ({ f, i })).sort((a, b) => (a.f.order ?? a.i) - (b.f.order ?? b.i) || a.i - b.i).map((x) => x.f);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Which HTML input a value type wants. Tenants may override per Field through presentation hints. */
|
|
52
|
+
export type Widget = "text" | "textarea" | "password" | "number" | "checkbox" | "date" | "datetime-local" | "email" | "tel" | "url" | "json" | "select" | "attachment";
|
|
53
|
+
|
|
54
|
+
export function widgetFor(valueType: FieldValueType | undefined, opts: { enum?: unknown[]; long?: boolean } = {}): Widget {
|
|
55
|
+
if (opts.enum?.length) return "select";
|
|
56
|
+
switch (valueType) {
|
|
57
|
+
case "password":
|
|
58
|
+
return "password";
|
|
59
|
+
case "number":
|
|
60
|
+
return "number";
|
|
61
|
+
case "boolean":
|
|
62
|
+
return "checkbox";
|
|
63
|
+
case "date":
|
|
64
|
+
return "date";
|
|
65
|
+
case "datetime":
|
|
66
|
+
return "datetime-local";
|
|
67
|
+
case "email":
|
|
68
|
+
return "email";
|
|
69
|
+
case "phone":
|
|
70
|
+
return "tel";
|
|
71
|
+
case "url":
|
|
72
|
+
return "url";
|
|
73
|
+
case "json":
|
|
74
|
+
return "json";
|
|
75
|
+
case "attachment":
|
|
76
|
+
return "attachment";
|
|
77
|
+
case "text":
|
|
78
|
+
default:
|
|
79
|
+
return opts.long ? "textarea" : "text";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Turn a form string into the typed value the Interface API validates (`validateFieldValue`). */
|
|
84
|
+
export function coerceInput(valueType: FieldValueType | undefined, raw: string | boolean | null | undefined): unknown {
|
|
85
|
+
if (raw === undefined || raw === null) return null;
|
|
86
|
+
if (typeof raw === "boolean") return raw;
|
|
87
|
+
const s = raw.trim();
|
|
88
|
+
if (s === "") return null;
|
|
89
|
+
switch (valueType) {
|
|
90
|
+
case "number": {
|
|
91
|
+
const n = Number(s);
|
|
92
|
+
return Number.isFinite(n) ? n : s;
|
|
93
|
+
}
|
|
94
|
+
case "boolean":
|
|
95
|
+
return s === "true" || s === "on" || s === "1";
|
|
96
|
+
case "json":
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(s);
|
|
99
|
+
} catch {
|
|
100
|
+
return s;
|
|
101
|
+
}
|
|
102
|
+
case "datetime":
|
|
103
|
+
// `datetime-local` yields a local wall-clock time without a zone; the API wants ISO 8601.
|
|
104
|
+
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/.test(s) ? new Date(s).toISOString() : s;
|
|
105
|
+
default:
|
|
106
|
+
return s;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A value for a form control (the reverse of {@link coerceInput}). */
|
|
111
|
+
export function inputValue(valueType: FieldValueType | undefined, value: unknown): string {
|
|
112
|
+
if (value === null || value === undefined) return "";
|
|
113
|
+
switch (valueType) {
|
|
114
|
+
case "json":
|
|
115
|
+
return typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
|
116
|
+
case "datetime":
|
|
117
|
+
if (typeof value === "string" && !Number.isNaN(Date.parse(value))) {
|
|
118
|
+
const d = new Date(value);
|
|
119
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
120
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
121
|
+
}
|
|
122
|
+
return String(value);
|
|
123
|
+
default:
|
|
124
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Human display of a value; secrets are shown only after a reveal, which the caller decides. */
|
|
129
|
+
export function formatValue(valueType: FieldValueType | undefined, value: unknown, locale = "en-GB"): string {
|
|
130
|
+
if (value === null || value === undefined || value === "") return "—";
|
|
131
|
+
switch (valueType) {
|
|
132
|
+
case "boolean":
|
|
133
|
+
return value ? "Yes" : "No";
|
|
134
|
+
case "date":
|
|
135
|
+
return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? new Intl.DateTimeFormat(locale, { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" }).format(new Date(value)) : String(value);
|
|
136
|
+
case "datetime":
|
|
137
|
+
return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : String(value);
|
|
138
|
+
case "number":
|
|
139
|
+
return typeof value === "number" ? new Intl.NumberFormat(locale).format(value) : String(value);
|
|
140
|
+
case "json":
|
|
141
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
142
|
+
default:
|
|
143
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The first non-secret Field value that can serve as an Entry's title, by preferred names then by order. */
|
|
148
|
+
export function entryTitle(entry: { label?: string; fields: Field[] }, preferredNames: readonly string[] = []): string {
|
|
149
|
+
if (entry.label) return entry.label;
|
|
150
|
+
for (const n of preferredNames) {
|
|
151
|
+
const f = entry.fields.find((x) => x.name === n && !x.secret && x.value !== null && x.value !== undefined && x.value !== "");
|
|
152
|
+
if (f) return String(f.value);
|
|
153
|
+
}
|
|
154
|
+
const f = entry.fields.find((x) => !x.secret && (x.value_type === "text" || x.value_type === "email" || x.value_type === "url") && typeof x.value === "string" && x.value);
|
|
155
|
+
return f ? String(f.value) : "Untitled";
|
|
156
|
+
}
|
package/src/enrolment.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closing enrolment (ADR-044): whether a Tenant takes new Customers. An
|
|
3
|
+
* administrator pauses it with a switch, or sets a maximum number of
|
|
4
|
+
* Customers after which it closes by itself. Existing Customers sign in
|
|
5
|
+
* either way; only a first sign-in that would enrol a Customer is refused.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
/** What `GET /v0/status/:arid` and `GET /v0/portal/presentation` say: no reason and no count. */
|
|
10
|
+
export type EnrolmentState = "open" | "closed";
|
package/src/fill.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fill modes (ADR-046). A Field's value type says what its value is; its fill
|
|
3
|
+
* mode says where the value comes from: `value` (the Field stores it),
|
|
4
|
+
* `internal` (the Field stores an ARID) or `external` (the Field stores a
|
|
5
|
+
* locator for something outside Asturiu, which the Engine never follows).
|
|
6
|
+
* Shared by the Engine and every client; dependency-free.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { FieldValueType } from "./types.js";
|
|
10
|
+
|
|
11
|
+
export const FILL_MODES = ["value", "internal", "external"] as const;
|
|
12
|
+
export type FillMode = (typeof FILL_MODES)[number];
|
|
13
|
+
|
|
14
|
+
/** The Engine's own limit on a locator; a Tenant's `locator.max_length` sits within it. */
|
|
15
|
+
export const LOCATOR_MAX_LENGTH = 2048;
|
|
16
|
+
/** A reference's optional label: as long as a Definition label. */
|
|
17
|
+
export const REFERENCE_LABEL_MAX = 200;
|
|
18
|
+
|
|
19
|
+
/** A Tenant's rule for the locators of one field. */
|
|
20
|
+
export interface LocatorRule {
|
|
21
|
+
pattern?: string;
|
|
22
|
+
max_length?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What a write sends in place of `value`: `PUT /v0/fields/{arid}`, and per field in Entry writes. */
|
|
26
|
+
export type FieldRefInput = { arid: string; label?: string | null } | { locator: string; label?: string | null };
|
|
27
|
+
|
|
28
|
+
/** `ref` on a Field filled by an internal reference. `readable` and `value_type` are left out under `?resolve=false`. */
|
|
29
|
+
export interface InternalFieldRef {
|
|
30
|
+
arid: string;
|
|
31
|
+
/** The ARID's type token. */
|
|
32
|
+
type: string;
|
|
33
|
+
label: string | null;
|
|
34
|
+
/** Whether the reader may read the target. `false` also when it does not exist; the two are not told apart. */
|
|
35
|
+
readable?: boolean;
|
|
36
|
+
/** For a Field target the reader may read: its value type, which may differ from the referencing Field's. */
|
|
37
|
+
value_type?: FieldValueType;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `ref` on a Field filled by an external reference: the locator exactly as written. */
|
|
41
|
+
export interface ExternalFieldRef {
|
|
42
|
+
locator: string;
|
|
43
|
+
label: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type FieldRef = InternalFieldRef | ExternalFieldRef;
|
|
47
|
+
|
|
48
|
+
export function isInternalRef(ref: FieldRef | null | undefined): ref is InternalFieldRef {
|
|
49
|
+
return !!ref && typeof (ref as InternalFieldRef).arid === "string";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isExternalRef(ref: FieldRef | null | undefined): ref is ExternalFieldRef {
|
|
53
|
+
return !!ref && typeof (ref as ExternalFieldRef).locator === "string";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The modes a field allows: every list given narrows the one before it, and
|
|
58
|
+
* the order of the narrowest list wins — its first mode is the one a form
|
|
59
|
+
* preselects. An absent or empty list narrows nothing, so with no lists at all
|
|
60
|
+
* every mode is allowed.
|
|
61
|
+
*/
|
|
62
|
+
export function effectiveFillModes(...lists: (readonly FillMode[] | null | undefined)[]): FillMode[] {
|
|
63
|
+
let out: FillMode[] = [...FILL_MODES];
|
|
64
|
+
for (const list of lists) {
|
|
65
|
+
if (!list?.length) continue;
|
|
66
|
+
out = list.filter((m, i) => out.includes(m) && list.indexOf(m) === i);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A Field's fill as a frontend reads it. */
|
|
72
|
+
export type FieldFillView = { mode: "value" } | { mode: "internal"; ref: InternalFieldRef } | { mode: "external"; ref: ExternalFieldRef };
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A Field's mode and, for a reference, where it points — from a Field
|
|
76
|
+
* representation. A representation from before ADR-046 carries no
|
|
77
|
+
* `filled_by` and reads as filled by value.
|
|
78
|
+
*/
|
|
79
|
+
export function fieldFillOf(field: { filled_by?: FillMode | null; ref?: FieldRef | null }): FieldFillView {
|
|
80
|
+
if (field.filled_by === "internal" && isInternalRef(field.ref)) return { mode: "internal", ref: field.ref };
|
|
81
|
+
if (field.filled_by === "external" && isExternalRef(field.ref)) return { mode: "external", ref: field.ref };
|
|
82
|
+
return { mode: "value" };
|
|
83
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@asturiu/v0-sdk`: the Tenant SDK for Asturiu v0 (ADR-074). Everything a
|
|
3
|
+
* Tenant's application calls, types against or writes files for: the `/v0`
|
|
4
|
+
* client and its types, the sign-in and the session cookies, the Definition
|
|
5
|
+
* set, branding and portal files, and the signed Tenant API calls.
|
|
6
|
+
*
|
|
7
|
+
* Dependency-free; runs in Node, edge runtimes and browsers. The Tenant API
|
|
8
|
+
* calls sign only on a server: a Tenant key never reaches a browser. The
|
|
9
|
+
* command line (`asturiu-v0-tenant`) and `@asturiu/v0-sdk/testing` run in
|
|
10
|
+
* Node and are never loaded from here.
|
|
11
|
+
*/
|
|
12
|
+
export * from "./version.js";
|
|
13
|
+
export * from "./types.js";
|
|
14
|
+
export * from "./client.js";
|
|
15
|
+
export * from "./oidc.js";
|
|
16
|
+
export * from "./seal.js";
|
|
17
|
+
export * from "./session.js";
|
|
18
|
+
export * from "./signing-in.js";
|
|
19
|
+
export * from "./definitions.js";
|
|
20
|
+
export * from "./urlid.js";
|
|
21
|
+
export * from "./tags.js";
|
|
22
|
+
export * from "./tiers.js";
|
|
23
|
+
export * from "./tickets.js";
|
|
24
|
+
export * from "./changesets.js";
|
|
25
|
+
export * from "./reveal.js";
|
|
26
|
+
export * from "./contacts.js";
|
|
27
|
+
export * from "./portal-options.js";
|
|
28
|
+
export * from "./branding.js";
|
|
29
|
+
export * from "./theme.js";
|
|
30
|
+
export * from "./overlay.js";
|
|
31
|
+
export * from "./definition-set.js";
|
|
32
|
+
export * from "./fill.js";
|
|
33
|
+
export * from "./passes.js";
|
|
34
|
+
export * from "./notifications.js";
|
|
35
|
+
export * from "./enrolment.js";
|
|
36
|
+
export * from "./reads.js";
|
|
37
|
+
export * from "./recovery.js";
|
|
38
|
+
export * from "./tenant-subscription.js";
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which Notifications a Customer also receives by e-mail (ADR-041 §5). Every
|
|
3
|
+
* choice is off until the Customer switches it on, per Tenant. Pure and
|
|
4
|
+
* dependency-free: the Interface API and the Operations API resolve a
|
|
5
|
+
* Notification's channel with these lists, the Customer Portal and a Tenant's
|
|
6
|
+
* app draw the switches from them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const NOTIFICATION_EMAIL_CHOICES = ["shares", "transfers", "contacts", "messages", "recovery", "triggers", "updates", "tickets"] as const;
|
|
10
|
+
export type NotificationEmailChoice = (typeof NOTIFICATION_EMAIL_CHOICES)[number];
|
|
11
|
+
|
|
12
|
+
/** The Notification kinds each choice covers. A kind that is in no list (`delivery_failed`) is in-app only. */
|
|
13
|
+
export const NOTIFICATION_EMAIL_KINDS: Record<NotificationEmailChoice, readonly string[]> = {
|
|
14
|
+
shares: ["grant", "pass_locked"],
|
|
15
|
+
transfers: ["transfer"],
|
|
16
|
+
contacts: ["contact"],
|
|
17
|
+
messages: ["message"],
|
|
18
|
+
// An invitation, a new card and the owner's alert are mailed whatever the choice (ADR-050 §7); the rest follow it.
|
|
19
|
+
recovery: ["recovery_invite", "recovery_card_replaced", "recovery_claim", "recovery_done", "recovery_ended"],
|
|
20
|
+
triggers: ["trigger_fired", "trigger_warning"],
|
|
21
|
+
updates: ["definitions_attention"],
|
|
22
|
+
// A reply on a request of the Customer's, or a move to waiting or resolved (ADR-058 §11).
|
|
23
|
+
tickets: ["ticket"],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** The choice a Notification kind falls under, or `null` when the kind never goes by e-mail to a Customer. */
|
|
27
|
+
export function emailChoiceOf(kind: string): NotificationEmailChoice | null {
|
|
28
|
+
for (const choice of NOTIFICATION_EMAIL_CHOICES) if (NOTIFICATION_EMAIL_KINDS[choice].includes(kind)) return choice;
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isNotificationEmailChoice(v: unknown): v is NotificationEmailChoice {
|
|
33
|
+
return typeof v === "string" && (NOTIFICATION_EMAIL_CHOICES as readonly string[]).includes(v);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Every choice with its value; what is not listed is off. */
|
|
37
|
+
export function emailChoicesFrom(on: readonly string[] | undefined | null): Record<NotificationEmailChoice, boolean> {
|
|
38
|
+
return Object.fromEntries(NOTIFICATION_EMAIL_CHOICES.map((c) => [c, !!on?.includes(c)])) as Record<NotificationEmailChoice, boolean>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `GET /v0/customer/notifications`. `available` is false while the Customer's
|
|
43
|
+
* sign-in has no verified e-mail address: then no switch can be turned on.
|
|
44
|
+
*/
|
|
45
|
+
export interface NotificationEmailSettings {
|
|
46
|
+
email: {
|
|
47
|
+
available: boolean;
|
|
48
|
+
kinds: Record<NotificationEmailChoice, boolean>;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `PUT /v0/customer/notifications`: name the choices to change; the rest keep their value. */
|
|
53
|
+
export interface NotificationEmailSettingsUpdate {
|
|
54
|
+
email: { kinds: Partial<Record<NotificationEmailChoice, boolean>> };
|
|
55
|
+
}
|
package/src/oidc.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OIDC helpers for the Frontend layer (components/02-customer-ui-web.md §Login
|
|
3
|
+
* completion; flows/authentication.md). Dependency-free: Web Crypto only, so
|
|
4
|
+
* the same code runs in a Next.js route handler, an edge runtime or Node.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Authentik OIDC endpoints for an Application slug, relative to a base URL. */
|
|
8
|
+
export function oidcEndpoints(base: string, slug: string) {
|
|
9
|
+
const b = base.replace(/\/+$/, "");
|
|
10
|
+
return {
|
|
11
|
+
issuer: `${b}/application/o/${slug}/`,
|
|
12
|
+
authorize: `${b}/application/o/authorize/`,
|
|
13
|
+
token: `${b}/application/o/token/`,
|
|
14
|
+
userinfo: `${b}/application/o/userinfo/`,
|
|
15
|
+
jwks: `${b}/application/o/${slug}/jwks/`,
|
|
16
|
+
endSession: `${b}/application/o/${slug}/end-session/`,
|
|
17
|
+
/** The Tenant's authentication, enrolment and recovery flows (components/11-authentik.md §One Application per Tenant). */
|
|
18
|
+
authentication: `${b}/if/flow/${tenantFlowSlug(slug, "authentication")}/`,
|
|
19
|
+
enrollment: `${b}/if/flow/${tenantFlowSlug(slug, "enrollment")}/`,
|
|
20
|
+
recovery: `${b}/if/flow/${tenantFlowSlug(slug, "recovery")}/`,
|
|
21
|
+
/** The step-up flows (ADR-018): confirm the password, or the password and a second factor, on the live Authentik session. */
|
|
22
|
+
stepUpPassword: `${b}/if/flow/${tenantFlowSlug(slug, "stepup-password")}/`,
|
|
23
|
+
stepUp2fa: `${b}/if/flow/${tenantFlowSlug(slug, "stepup-2fa")}/`,
|
|
24
|
+
/** Change the password (ADR-022); ends in a fresh authorization request. */
|
|
25
|
+
passwordChange: `${b}/if/flow/${tenantFlowSlug(slug, "password-change")}/`,
|
|
26
|
+
/** Register with the Tenant using an existing shared account: its own sign-in, then a review (ADR-043, ADR-051); `shared` Tenants only. */
|
|
27
|
+
join: `${b}/if/flow/${tenantFlowSlug(slug, "join")}/`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The message of a Tenant sign-in that refuses an account outside the Tenant
|
|
33
|
+
* (ADR-051): the Operations API's blueprint gives its deny stage this text,
|
|
34
|
+
* and the Customer Portal recognises it to show its own "register first" step.
|
|
35
|
+
* Readable as it is, for any page that shows Authentik's own words.
|
|
36
|
+
*/
|
|
37
|
+
export const NOT_REGISTERED_DENIAL = "This account is not registered here yet. Register first, then sign in.";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The message of a sign-up form that refuses an e-mail address another account
|
|
41
|
+
* has (ADR-053): the Operations API's blueprint gives the enrolment prompt's
|
|
42
|
+
* validation policy this text, and the Customer Portal recognises it to show
|
|
43
|
+
* it in the page's language. It says nothing about who has the address.
|
|
44
|
+
*/
|
|
45
|
+
export const EMAIL_NOT_ALLOWED = "This e-mail address is not allowed.";
|
|
46
|
+
|
|
47
|
+
/** Every flow the Operations API provisions per Tenant, named `<slug>-<kind>` (components/11-authentik.md). */
|
|
48
|
+
export const TENANT_FLOW_KINDS = ["authentication", "enrollment", "recovery", "invalidation", "stepup-password", "stepup-2fa", "password-change", "source-enrollment", "source-authentication", "join"] as const;
|
|
49
|
+
/** The platform's account Application's flows (ADR-022, ADR-032; the step-ups since ADR-022 build note 1). */
|
|
50
|
+
export const ACCOUNT_FLOW_KINDS = ["authentication", "recovery", "invalidation", "password-change", "stepup-password", "stepup-2fa"] as const;
|
|
51
|
+
/** The administrative Application's flows (ADR-032). */
|
|
52
|
+
export const ADMIN_FLOW_KINDS = ["authentication", "invalidation"] as const;
|
|
53
|
+
|
|
54
|
+
export type TenantFlowKind = (typeof TENANT_FLOW_KINDS)[number];
|
|
55
|
+
|
|
56
|
+
/** A flow slug: `<slug>-<kind>`, as the Operations API names every flow it provisions. */
|
|
57
|
+
export function tenantFlowSlug(slug: string, kind: TenantFlowKind): string {
|
|
58
|
+
return `${slug}-${kind}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The exact flow slugs an Application owns (ADR-032). A sign-in host runs
|
|
63
|
+
* these and nothing else; a prefix would not do, because slugs contain
|
|
64
|
+
* hyphens — `acme-` also matches `acme-labs-…`.
|
|
65
|
+
*/
|
|
66
|
+
export function flowSlugsOf(slug: string, kinds: readonly TenantFlowKind[] = TENANT_FLOW_KINDS): string[] {
|
|
67
|
+
return kinds.map((k) => tenantFlowSlug(slug, k));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Run a Tenant flow first, then the authorization request: Authentik executes
|
|
72
|
+
* the flow and, when its last stage is done, continues to `next` — the
|
|
73
|
+
* authorization request — so the Web UI's callback completes as usual. Used
|
|
74
|
+
* for enrolment (a session exists once the login stage ran) and for step-up
|
|
75
|
+
* (the session's login event is fresh, so the token proves what just happened).
|
|
76
|
+
*/
|
|
77
|
+
export function buildFlowUrl(flowEndpoint: string, authorizeUrl: string): string {
|
|
78
|
+
const url = new URL(flowEndpoint);
|
|
79
|
+
const authorize = new URL(authorizeUrl);
|
|
80
|
+
// Authentik accepts `next` as a path on its own host (the form its own redirects use); an absolute URL is not carried through the flow.
|
|
81
|
+
url.searchParams.set("next", authorize.origin === url.origin ? `${authorize.pathname}${authorize.search}` : authorizeUrl);
|
|
82
|
+
return url.toString();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Start enrolment instead of login (see `buildFlowUrl`). */
|
|
86
|
+
export function buildEnrollmentUrl(enrollmentEndpoint: string, authorizeUrl: string): string {
|
|
87
|
+
return buildFlowUrl(enrollmentEndpoint, authorizeUrl);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type OidcEndpoints = ReturnType<typeof oidcEndpoints>;
|
|
91
|
+
|
|
92
|
+
export const OIDC_SCOPE = "openid email profile";
|
|
93
|
+
|
|
94
|
+
function b64url(bytes: Uint8Array): string {
|
|
95
|
+
let s = "";
|
|
96
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
97
|
+
const base64 = typeof btoa === "function" ? btoa(s) : Buffer.from(bytes).toString("base64");
|
|
98
|
+
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function randomToken(bytes = 32): string {
|
|
102
|
+
const out = new Uint8Array(bytes);
|
|
103
|
+
crypto.getRandomValues(out);
|
|
104
|
+
return b64url(out);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface Pkce {
|
|
108
|
+
verifier: string;
|
|
109
|
+
challenge: string;
|
|
110
|
+
method: "S256";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A fresh PKCE pair per login attempt (RFC 7636, S256). */
|
|
114
|
+
export async function createPkce(): Promise<Pkce> {
|
|
115
|
+
const verifier = randomToken(48);
|
|
116
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
117
|
+
return { verifier, challenge: b64url(new Uint8Array(digest)), method: "S256" };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface AuthorizeRequest {
|
|
121
|
+
authorizeEndpoint: string;
|
|
122
|
+
clientId: string;
|
|
123
|
+
redirectUri: string;
|
|
124
|
+
state: string;
|
|
125
|
+
codeChallenge: string;
|
|
126
|
+
scope?: string;
|
|
127
|
+
/** Passed through to Authentik as-is (`prompt`, `login_hint`, …). */
|
|
128
|
+
extra?: Record<string, string>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The authorization-code request the Web UI starts; PKCE and `state` are mandatory. */
|
|
132
|
+
export function buildAuthorizeUrl(r: AuthorizeRequest): string {
|
|
133
|
+
const url = new URL(r.authorizeEndpoint);
|
|
134
|
+
url.searchParams.set("response_type", "code");
|
|
135
|
+
url.searchParams.set("client_id", r.clientId);
|
|
136
|
+
url.searchParams.set("redirect_uri", r.redirectUri);
|
|
137
|
+
url.searchParams.set("scope", r.scope ?? OIDC_SCOPE);
|
|
138
|
+
url.searchParams.set("state", r.state);
|
|
139
|
+
url.searchParams.set("code_challenge", r.codeChallenge);
|
|
140
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
141
|
+
for (const [k, v] of Object.entries(r.extra ?? {})) url.searchParams.set(k, v);
|
|
142
|
+
return url.toString();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface TokenResponse {
|
|
146
|
+
access_token: string;
|
|
147
|
+
id_token?: string;
|
|
148
|
+
token_type?: string;
|
|
149
|
+
expires_in?: number;
|
|
150
|
+
scope?: string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export class OidcError extends Error {
|
|
154
|
+
constructor(
|
|
155
|
+
readonly code: string,
|
|
156
|
+
readonly description?: string,
|
|
157
|
+
readonly status?: number,
|
|
158
|
+
) {
|
|
159
|
+
super(description ? `${code}: ${description}` : code);
|
|
160
|
+
this.name = "OidcError";
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface CodeExchange {
|
|
165
|
+
tokenEndpoint: string;
|
|
166
|
+
clientId: string;
|
|
167
|
+
code: string;
|
|
168
|
+
redirectUri: string;
|
|
169
|
+
codeVerifier: string;
|
|
170
|
+
/** Confidential clients only; Tenant Providers are public clients. */
|
|
171
|
+
clientSecret?: string;
|
|
172
|
+
fetch?: typeof fetch;
|
|
173
|
+
timeoutMs?: number;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Back-channel code exchange with the PKCE verifier. The token is exchanged for a session and then discarded. */
|
|
177
|
+
export async function exchangeCode(x: CodeExchange): Promise<TokenResponse> {
|
|
178
|
+
const form = new URLSearchParams({ grant_type: "authorization_code", client_id: x.clientId, code: x.code, redirect_uri: x.redirectUri, code_verifier: x.codeVerifier });
|
|
179
|
+
if (x.clientSecret) form.set("client_secret", x.clientSecret);
|
|
180
|
+
const f: typeof fetch = x.fetch ?? ((input, init) => globalThis.fetch(input, init)); // called on the global, never as a detached method
|
|
181
|
+
const res = await f(x.tokenEndpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, body: form, signal: AbortSignal.timeout(x.timeoutMs ?? 15000) });
|
|
182
|
+
const json = (await res.json().catch(() => ({}))) as Partial<TokenResponse> & { error?: string; error_description?: string };
|
|
183
|
+
if (!res.ok || !json.access_token) throw new OidcError(json.error ?? `http_${res.status}`, json.error_description, res.status);
|
|
184
|
+
return json as TokenResponse;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Where to send the browser after the Engine session is gone, so Authentik's
|
|
189
|
+
* own session ends too. Authentik (2025.10) does not read
|
|
190
|
+
* `post_logout_redirect_uri`: the Tenant's invalidation flow signs the person
|
|
191
|
+
* out and redirects to the Tenant's site (`branding.product_url`,
|
|
192
|
+
* components/11-authentik.md §No portal for customers). The parameter is kept
|
|
193
|
+
* for a conforming provider.
|
|
194
|
+
*/
|
|
195
|
+
export function buildEndSessionUrl(endSessionEndpoint: string, postLogoutRedirectUri?: string): string {
|
|
196
|
+
const url = new URL(endSessionEndpoint);
|
|
197
|
+
if (postLogoutRedirectUri) url.searchParams.set("post_logout_redirect_uri", postLogoutRedirectUri);
|
|
198
|
+
return url.toString();
|
|
199
|
+
}
|