@drawnagency/primitives 0.1.105 → 0.1.106

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.
Files changed (95) hide show
  1. package/dist/auth/cookies.d.ts +21 -1
  2. package/dist/auth/cookies.d.ts.map +1 -1
  3. package/dist/auth/index.d.ts +2 -1
  4. package/dist/auth/index.d.ts.map +1 -1
  5. package/dist/auth/index.js +3 -1
  6. package/dist/auth/types.d.ts +28 -0
  7. package/dist/auth/types.d.ts.map +1 -1
  8. package/dist/{chunk-PTOLUMGV.js → chunk-4RNF2DRG.js} +1 -0
  9. package/dist/{chunk-MUTHF2GT.js → chunk-5IZ2KTMU.js} +2 -2
  10. package/dist/{chunk-LZKVV4K2.js → chunk-6P4CJCXB.js} +7 -1
  11. package/dist/{chunk-CZLYECHC.js → chunk-AB3XVXI3.js} +4 -4
  12. package/dist/chunk-B7VBSE5X.js +12 -0
  13. package/dist/{chunk-BGHWUJFV.js → chunk-BOZUEKWL.js} +107 -171
  14. package/dist/{chunk-TGNS7D4F.js → chunk-CPTFFLK2.js} +1 -1
  15. package/dist/{chunk-26ALJO4K.js → chunk-D6TLUVOP.js} +1 -1
  16. package/dist/{chunk-EM5DFHDZ.js → chunk-GEENI3OC.js} +13 -4
  17. package/dist/chunk-GGXZZ77Z.js +70 -0
  18. package/dist/{chunk-EWNDBKYK.js → chunk-LUAI5DWR.js} +1 -1
  19. package/dist/chunk-NLQ5F3RQ.js +43 -0
  20. package/dist/{chunk-X4PRBGB3.js → chunk-QLBHERK6.js} +9 -9
  21. package/dist/{chunk-3COSXTOY.js → chunk-XBYJQ5RS.js} +1 -1
  22. package/dist/{chunk-CXQWLG6X.js → chunk-Z7VXESB6.js} +18 -18
  23. package/dist/{chunk-WA6QPDJI.js → chunk-ZHHJVKU7.js} +6 -6
  24. package/dist/collab/rebase.js +2 -1
  25. package/dist/components/editor/SectionOrderingModal.d.ts.map +1 -1
  26. package/dist/components/editor/index.js +8 -7
  27. package/dist/components/primitives/index.js +7 -6
  28. package/dist/components/sections/brand-guide.js +8 -7
  29. package/dist/components/sections/register-schemas.js +13 -12
  30. package/dist/components/sections/register.js +13 -12
  31. package/dist/components/shared/Navigation.d.ts +16 -1
  32. package/dist/components/shared/Navigation.d.ts.map +1 -1
  33. package/dist/components/shared/SectionSummaryRow.d.ts +24 -0
  34. package/dist/components/shared/SectionSummaryRow.d.ts.map +1 -0
  35. package/dist/components/shell/AudienceAddForm.d.ts +4 -1
  36. package/dist/components/shell/AudienceAddForm.d.ts.map +1 -1
  37. package/dist/components/shell/AudienceDetailsPanel.d.ts +28 -0
  38. package/dist/components/shell/AudienceDetailsPanel.d.ts.map +1 -0
  39. package/dist/components/shell/AudienceRow.d.ts +15 -1
  40. package/dist/components/shell/AudienceRow.d.ts.map +1 -1
  41. package/dist/components/shell/CredentialList.d.ts +18 -0
  42. package/dist/components/shell/CredentialList.d.ts.map +1 -0
  43. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  44. package/dist/components/shell/SiteSettingsViewerAccess.d.ts.map +1 -1
  45. package/dist/components/shell/ViewerLoginForm.d.ts +5 -1
  46. package/dist/components/shell/ViewerLoginForm.d.ts.map +1 -1
  47. package/dist/index.js +47 -29
  48. package/dist/lib/audience-overview.d.ts +53 -0
  49. package/dist/lib/audience-overview.d.ts.map +1 -0
  50. package/dist/lib/audience-secret.d.ts +11 -0
  51. package/dist/lib/audience-secret.d.ts.map +1 -0
  52. package/dist/lib/audience-secret.js +70 -0
  53. package/dist/lib/events.d.ts +3 -0
  54. package/dist/lib/events.d.ts.map +1 -1
  55. package/dist/lib/index-ops.js +3 -2
  56. package/dist/lib/index.js +13 -12
  57. package/dist/lib/nav.d.ts.map +1 -1
  58. package/dist/lib/thumb-views.d.ts +17 -0
  59. package/dist/lib/thumb-views.d.ts.map +1 -0
  60. package/dist/media/index.js +4 -3
  61. package/dist/schemas/audience.d.ts +29 -0
  62. package/dist/schemas/audience.d.ts.map +1 -1
  63. package/dist/schemas/audience.js +24 -0
  64. package/dist/schemas/auth.d.ts +1 -0
  65. package/dist/schemas/auth.d.ts.map +1 -1
  66. package/dist/schemas/auth.js +1 -1
  67. package/dist/schemas/index.js +28 -12
  68. package/dist/schemas/site-config.d.ts +1 -1
  69. package/dist/schemas/site-config.d.ts.map +1 -1
  70. package/dist/types/database.d.ts +59 -5
  71. package/dist/types/database.d.ts.map +1 -1
  72. package/package.json +9 -1
  73. package/src/auth/cookies.ts +37 -4
  74. package/src/auth/index.ts +2 -1
  75. package/src/auth/types.ts +32 -0
  76. package/src/components/editor/SectionOrderingModal.tsx +8 -73
  77. package/src/components/shared/Navigation.tsx +113 -7
  78. package/src/components/shared/SectionSummaryRow.tsx +73 -0
  79. package/src/components/shell/AudienceAddForm.tsx +18 -9
  80. package/src/components/shell/AudienceDetailsPanel.tsx +263 -0
  81. package/src/components/shell/AudienceRow.tsx +55 -13
  82. package/src/components/shell/CredentialList.tsx +285 -0
  83. package/src/components/shell/EditorShell.tsx +94 -4
  84. package/src/components/shell/SiteSettingsViewerAccess.tsx +57 -6
  85. package/src/components/shell/ViewerLoginForm.tsx +35 -15
  86. package/src/lib/audience-overview.ts +110 -0
  87. package/src/lib/audience-secret.ts +99 -0
  88. package/src/lib/events.ts +3 -0
  89. package/src/lib/nav.ts +6 -2
  90. package/src/lib/thumb-views.ts +28 -0
  91. package/src/schemas/audience.ts +52 -0
  92. package/src/schemas/auth.ts +6 -0
  93. package/src/schemas/site-config.ts +1 -1
  94. package/src/types/database.ts +59 -5
  95. package/dist/chunk-ZDQ3TT47.js +0 -30
@@ -1,16 +1,25 @@
1
1
  import { useState, type FormEvent } from "react";
2
+ import { Input } from "../shared/Input";
2
3
  import { PasswordInput } from "../shared/PasswordInput";
3
4
  import { Button } from "../shared/Button";
4
5
  import { Select } from "../shared/Select";
5
6
  import { safeRedirect } from "../../lib/safeRedirect";
6
7
 
7
8
  interface Props {
9
+ /** Audience mode only. Empty (and unused) in credential mode — the page must
10
+ * not ship an audience list to a login form that doesn't offer the choice. */
8
11
  audiences: Array<{ name: string; displayName: string; hasPassword?: boolean }>;
12
+ /** Set by the page from the auth provider's capability, never by the client. */
13
+ mode?: "audience" | "credentials";
9
14
  next?: string | null;
10
15
  }
11
16
 
12
- export function ViewerLoginForm({ audiences, next }: Props) {
13
- const [audience, setAudience] = useState(audiences.length === 1 ? audiences[0].name : "");
17
+ export function ViewerLoginForm({ audiences, mode = "audience", next }: Props) {
18
+ const credentialMode = mode === "credentials";
19
+ const [username, setUsername] = useState("");
20
+ const [audience, setAudience] = useState(
21
+ !credentialMode && audiences.length === 1 ? audiences[0].name : "",
22
+ );
14
23
  const [password, setPassword] = useState("");
15
24
  const [error, setError] = useState<string | null>(null);
16
25
  const [loading, setLoading] = useState(false);
@@ -22,19 +31,19 @@ export function ViewerLoginForm({ audiences, next }: Props) {
22
31
 
23
32
  // An open (passwordless) audience needs no password field — enter with one click.
24
33
  // Default to requiring a password until an audience is chosen so the field
25
- // doesn't flash for the "Select audience..." state.
34
+ // doesn't flash for the "Select audience..." state. Credential mode always
35
+ // takes a password.
26
36
  const selected = audiences.find((a) => a.name === audience);
27
- const needsPassword = selected ? selected.hasPassword !== false : true;
37
+ const needsPassword = credentialMode || (selected ? selected.hasPassword !== false : true);
28
38
 
29
39
  async function handleSubmit(e: FormEvent) {
30
40
  e.preventDefault();
31
41
  setLoading(true);
32
42
  setError(null);
33
43
 
34
- const body: { audience: string; password: string; next?: string } = {
35
- audience,
36
- password: needsPassword ? password : "",
37
- };
44
+ const body: { audience?: string; username?: string; password: string; next?: string } = credentialMode
45
+ ? { username: username.trim().toLowerCase(), password }
46
+ : { audience, password: needsPassword ? password : "" };
38
47
  if (next) body.next = next;
39
48
 
40
49
  try {
@@ -62,12 +71,23 @@ export function ViewerLoginForm({ audiences, next }: Props) {
62
71
  return (
63
72
  <div className="mx-auto max-w-sm">
64
73
  <form onSubmit={handleSubmit} className="space-y-4">
65
- <Select
66
- label="Audience"
67
- value={audience}
68
- onChange={setAudience}
69
- options={audienceOptions}
70
- />
74
+ {credentialMode ? (
75
+ <Input
76
+ label="Username"
77
+ value={username}
78
+ onChange={setUsername}
79
+ autoComplete="username"
80
+ autoCapitalize="none"
81
+ spellCheck={false}
82
+ />
83
+ ) : (
84
+ <Select
85
+ label="Audience"
86
+ value={audience}
87
+ onChange={setAudience}
88
+ options={audienceOptions}
89
+ />
90
+ )}
71
91
 
72
92
  {needsPassword && (
73
93
  <PasswordInput
@@ -86,7 +106,7 @@ export function ViewerLoginForm({ audiences, next }: Props) {
86
106
  type="submit"
87
107
  variant="brand"
88
108
  size="md"
89
- disabled={loading || !audience}
109
+ disabled={loading || (credentialMode ? username.trim().length === 0 : !audience)}
90
110
  isLoading={loading}
91
111
  loadingLabel="Verifying..."
92
112
  className="w-full"
@@ -0,0 +1,110 @@
1
+ /**
2
+ * What each audience can actually see. Powers the Audience Details page, which
3
+ * answers "if I give a partner this password, what do they get?".
4
+ *
5
+ * Pure and view-agnostic: section labels/thumbnails are resolved by the caller
6
+ * (they need the section registry and the media provider) and passed in, so this
7
+ * stays trivially testable and ships no registry into viewer JS.
8
+ */
9
+ import type { SiteIndex } from "../schemas/site-config";
10
+ import { audiencePasses, pageDisplayTitle } from "./pages";
11
+ import type { ThumbView } from "./thumb-views";
12
+
13
+ /** Caller-resolved presentation for one section. */
14
+ export interface SectionDisplay {
15
+ label: string;
16
+ typeLabel: string;
17
+ thumbnails: ThumbView[];
18
+ }
19
+
20
+ export interface OverviewSection extends SectionDisplay {
21
+ id: string;
22
+ available: boolean;
23
+ /** Live-but-draft sections are invisible to EVERY audience, not just this one. */
24
+ draft: boolean;
25
+ }
26
+
27
+ export interface OverviewPage {
28
+ id: string;
29
+ title: string;
30
+ slug: string;
31
+ href: string;
32
+ /** Live but off the sidebar — reachable by URL, so it counts as available. */
33
+ hiddenFromNav: boolean;
34
+ /** none = the page itself is gated away; partial = page in, some sections out. */
35
+ availability: "full" | "partial" | "none";
36
+ sections: OverviewSection[];
37
+ }
38
+
39
+ export interface AudienceOverview {
40
+ audience: string;
41
+ pages: OverviewPage[];
42
+ pagesAvailable: number;
43
+ pagesTotal: number;
44
+ sectionsAvailable: number;
45
+ sectionsTotal: number;
46
+ }
47
+
48
+ /**
49
+ * Availability for one audience across the whole site.
50
+ *
51
+ * Archived pages are excluded outright (nobody can reach them). Draft sections
52
+ * are listed as unavailable but flagged, so a page reading "3 of 8" is not
53
+ * mistaken for an access problem.
54
+ *
55
+ * The cascade matters: a section whose own access allows this audience is still
56
+ * unavailable inside a page that gates them out. Page access wins.
57
+ */
58
+ export function buildAudienceOverview(
59
+ index: SiteIndex,
60
+ display: (sectionId: string) => SectionDisplay,
61
+ audience: string,
62
+ ): AudienceOverview {
63
+ const pages: OverviewPage[] = [];
64
+ let sectionsAvailable = 0;
65
+ let sectionsTotal = 0;
66
+ let pagesAvailable = 0;
67
+
68
+ for (const page of index.pages) {
69
+ if (page.status === "archived") continue;
70
+ const pageOpen = audiencePasses(page.access, audience);
71
+ if (pageOpen) pagesAvailable++;
72
+
73
+ const sections: OverviewSection[] = [];
74
+ for (const id of page.order) {
75
+ const meta = index.sections[id];
76
+ // Not in the index — the loader would drop it too; nothing to report.
77
+ if (!meta) continue;
78
+ if (meta.status === "archived") continue;
79
+ const draft = meta.status !== "live";
80
+ const available = pageOpen && !draft && audiencePasses(meta.access, audience);
81
+ sectionsTotal++;
82
+ if (available) sectionsAvailable++;
83
+ sections.push({ id, available, draft, ...display(id) });
84
+ }
85
+
86
+ const availableHere = sections.filter((s) => s.available).length;
87
+ pages.push({
88
+ id: page.id,
89
+ title: pageDisplayTitle(page.title),
90
+ slug: page.slug,
91
+ href: page.slug === "" ? "/" : `/${page.slug}`,
92
+ hiddenFromNav: !page.showInNav,
93
+ availability: !pageOpen
94
+ ? "none"
95
+ : availableHere === sections.length
96
+ ? "full"
97
+ : "partial",
98
+ sections,
99
+ });
100
+ }
101
+
102
+ return {
103
+ audience,
104
+ pages,
105
+ pagesAvailable,
106
+ pagesTotal: pages.length,
107
+ sectionsAvailable,
108
+ sectionsTotal,
109
+ };
110
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Reversible storage for audience (viewer) passwords, so an editor or the
3
+ * default audience can be SHOWN an existing password rather than only replacing
4
+ * it. `password_hash` is bcrypt and one-way, so a sealed copy is stored beside
5
+ * it and opened on demand.
6
+ *
7
+ * The key is derived from the site's own SESSION_SECRET via HKDF — no new env
8
+ * var, no provisioner change, and (in platform mode) the shared Supabase project
9
+ * stores only a blob it cannot open, because the secret lives in the client
10
+ * site's Netlify env alone.
11
+ *
12
+ * This is envelope encryption with a derived key, NOT a KMS: whoever holds both
13
+ * SESSION_SECRET and the row recovers the password. That is the same secret that
14
+ * already signs every session cookie, and the protected value is a shared
15
+ * viewing password — an accepted bar, stated so it is never mistaken for more.
16
+ *
17
+ * WebCrypto only, no `node:crypto` at module scope: auth-supabase/supabase.ts
18
+ * value-imports this and is browser-reachable via portal.config.mjs (see
19
+ * .claude/rules/dev-server-symlinks.md and the sigv4.ts precedent).
20
+ */
21
+ import { requireSessionSecret } from "../auth/security";
22
+
23
+ /** Domain separation: this key must never coincide with a future HKDF use. */
24
+ const INFO = "drawnagency:audience-password:v1";
25
+ const VERSION = "v1";
26
+ const IV_BYTES = 12;
27
+
28
+ const enc = new TextEncoder();
29
+ const dec = new TextDecoder();
30
+
31
+ function toBase64Url(bytes: Uint8Array): string {
32
+ let binary = "";
33
+ for (const b of bytes) binary += String.fromCharCode(b);
34
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
35
+ }
36
+
37
+ function fromBase64Url(value: string): Uint8Array {
38
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/");
39
+ const binary = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
40
+ const bytes = new Uint8Array(binary.length);
41
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
42
+ return bytes;
43
+ }
44
+
45
+ // Derived per call rather than memoised: HKDF is cheap next to the bcrypt
46
+ // compare that always accompanies it, and a cache would outlive a rotated
47
+ // SESSION_SECRET within a warm serverless process.
48
+ async function deriveKey(): Promise<CryptoKey> {
49
+ const base = await crypto.subtle.importKey(
50
+ "raw",
51
+ requireSessionSecret() as BufferSource,
52
+ "HKDF",
53
+ false,
54
+ ["deriveKey"],
55
+ );
56
+ return crypto.subtle.deriveKey(
57
+ { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: enc.encode(INFO) },
58
+ base,
59
+ { name: "AES-GCM", length: 256 },
60
+ false,
61
+ ["encrypt", "decrypt"],
62
+ );
63
+ }
64
+
65
+ /** Seal a plaintext password into `v1.<iv>.<ciphertext>` (base64url parts). */
66
+ export async function sealAudiencePassword(password: string): Promise<string> {
67
+ const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
68
+ const ciphertext = await crypto.subtle.encrypt(
69
+ { name: "AES-GCM", iv: iv as BufferSource },
70
+ await deriveKey(),
71
+ enc.encode(password) as BufferSource,
72
+ );
73
+ return `${VERSION}.${toBase64Url(iv)}.${toBase64Url(new Uint8Array(ciphertext))}`;
74
+ }
75
+
76
+ /**
77
+ * Open a sealed password, or null when it cannot be trusted — unknown version,
78
+ * malformed parts, tampered ciphertext (GCM auth failure), or a rotated
79
+ * SESSION_SECRET. Callers treat null as "not revealable" and never as an error;
80
+ * they must ALSO verify the result against the bcrypt hash, since a valid seal
81
+ * can still be stale (a password changed by a writer that skipped the cipher).
82
+ */
83
+ export async function openAudiencePassword(sealed: string | null | undefined): Promise<string | null> {
84
+ if (!sealed) return null;
85
+ const parts = sealed.split(".");
86
+ if (parts.length !== 3 || parts[0] !== VERSION) return null;
87
+ try {
88
+ const iv = fromBase64Url(parts[1]);
89
+ if (iv.byteLength !== IV_BYTES) return null;
90
+ const plain = await crypto.subtle.decrypt(
91
+ { name: "AES-GCM", iv: iv as BufferSource },
92
+ await deriveKey(),
93
+ fromBase64Url(parts[2]) as BufferSource,
94
+ );
95
+ return dec.decode(plain);
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
package/src/lib/events.ts CHANGED
@@ -36,3 +36,6 @@ export const historyRestoreFailedEvent = createEvent<{ message: string }>("histo
36
36
  export const editorDirtyEvent = createEvent<{ hasUnsavedChanges: boolean }>("editor-dirty");
37
37
  export const siteNavChangeEvent = createEvent<SiteNav>("sitenavchange-v2");
38
38
  export const pageSelectEvent = createEvent<string>("pageselect");
39
+ /** Nav → EditorShell: open the Audience Details surface inside the editor, so
40
+ * editors keep the toolbar instead of navigating out to the /audiences route. */
41
+ export const audienceDetailsEvent = createEvent<void>("audiencedetailsopen");
package/src/lib/nav.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { LoadedSection } from "./loader";
2
2
  import { getSection, type SectionRegistry } from "./registry";
3
- import { audiencePasses, homePage, pageDisplayTitle } from "./pages";
3
+ import { audiencePasses, pageDisplayTitle } from "./pages";
4
4
  import { navEntries } from "./nav-tree";
5
5
  import type { SiteIndex } from "../schemas/site-config";
6
6
  import type { SiteDocument } from "./documents";
@@ -156,7 +156,11 @@ export function generateSiteNav(opts: {
156
156
  documents?: SiteDocument[];
157
157
  }): SiteNav {
158
158
  const { index, activeSections, activePageId, mode, audience, registry, documents } = opts;
159
- const active = activePageId ?? homePage(index).id;
159
+ // null means NO page is active — not "default to home". Surfaces that are not
160
+ // a page (the Audience Details route and its in-editor twin) rely on that: a
161
+ // page must not keep its highlight while you are somewhere else, or the
162
+ // sidebar claims you are in two places at once.
163
+ const active = activePageId;
160
164
  const byId = new Map(index.pages.map((p) => [p.id, p]));
161
165
 
162
166
  const toNavItem = (p: (typeof index.pages)[number]): PageNavItem => ({
@@ -0,0 +1,28 @@
1
+ import type { Thumbnail } from "./registry";
2
+ import { getMediaProvider } from "../media";
3
+ import type { MediaManifest } from "../media/types";
4
+
5
+ /**
6
+ * A section thumbnail with its media reference already resolved to a URL, so the
7
+ * component rendering it needs neither the registry nor the media provider. The
8
+ * Audience Details page resolves these server-side for exactly that reason.
9
+ */
10
+ export type ThumbView =
11
+ | { kind: "color"; value: string }
12
+ | { kind: "image"; src: string | null; alt: string };
13
+
14
+ function resolveThumbnailSrc(thumb: Thumbnail & { type: "image" }, manifest: MediaManifest): string | null {
15
+ const item = manifest.images[thumb.src];
16
+ if (!item) return null;
17
+ const resolved = getMediaProvider().resolve(item, [200]);
18
+ if (!resolved) return null;
19
+ return resolved.tag === "img" || resolved.tag === "video" ? resolved.src : null;
20
+ }
21
+
22
+ export function toThumbViews(thumbnails: Thumbnail[], manifest: MediaManifest): ThumbView[] {
23
+ return thumbnails.map((thumb) =>
24
+ thumb.type === "color"
25
+ ? { kind: "color", value: thumb.value }
26
+ : { kind: "image", src: resolveThumbnailSrc(thumb, manifest), alt: thumb.alt ?? "" },
27
+ );
28
+ }
@@ -15,6 +15,58 @@ export const AudienceColorSchema = HexColorSchema.nullable();
15
15
  export type AudienceName = z.infer<typeof AudienceNameSchema>;
16
16
  export type AudienceColor = z.infer<typeof AudienceColorSchema>;
17
17
 
18
+ /**
19
+ * A viewer sign-in name. Looser than an audience slug — dots are allowed, so
20
+ * `agency.jane` works — but still constrained enough to be typed correctly over
21
+ * the phone. Always lowercased before storage and lookup, so `Agency2` and
22
+ * `agency2` are the same login.
23
+ */
24
+ export const ViewerUsernameSchema = z
25
+ .string()
26
+ .min(1)
27
+ .max(64)
28
+ .regex(
29
+ /^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/,
30
+ "lowercase alphanumeric; dots, dashes and underscores allowed",
31
+ );
32
+
33
+ /** Normalize a typed username for storage and lookup. */
34
+ export function normalizeUsername(input: string): string {
35
+ return input.trim().toLowerCase();
36
+ }
37
+
38
+ /**
39
+ * Wire-facing username: lowercased and trimmed BEFORE validation, so `Agency4`
40
+ * is accepted and stored as `agency4` rather than 400-ing on a case difference.
41
+ * Everything else the base schema rejects still fails.
42
+ */
43
+ export const NormalizedUsernameSchema = z
44
+ .string()
45
+ .min(1)
46
+ .max(128)
47
+ .transform(normalizeUsername)
48
+ .pipe(ViewerUsernameSchema);
49
+
50
+ /** Minimum for a NEW credential password. Deliberately stricter than the
51
+ * audience passwords it replaces (which allowed a single character). */
52
+ export const VIEWER_PASSWORD_MIN_LENGTH = 8;
53
+
54
+ export const ViewerPasswordSchema = z.string().min(VIEWER_PASSWORD_MIN_LENGTH);
55
+
56
+ /** A credential as it travels to the editor and the Audience Details page.
57
+ * Never carries the hash or the sealed copy. */
58
+ export const ViewerCredentialSchema = z.object({
59
+ username: z.string(),
60
+ label: z.string().nullable(),
61
+ /** The audience slug this sign-in grants — its content access, verbatim. */
62
+ audienceName: z.string(),
63
+ createdAt: z.string(),
64
+ });
65
+
66
+ export type ViewerUsername = z.infer<typeof ViewerUsernameSchema>;
67
+ export type ViewerCredential = z.infer<typeof ViewerCredentialSchema>;
68
+
69
+
18
70
  /**
19
71
  * Convert a friendly display string into an audience slug.
20
72
  * Lowercases, replaces non-alphanumeric runs with single dashes,
@@ -21,8 +21,14 @@ export const AudienceSchema = z.object({
21
21
  displayName: z.string(),
22
22
  color: z.string().nullable(),
23
23
  readonly: z.boolean(),
24
+ /** Some way in exists: a credential in credential mode, an audience-level
25
+ * password in `.env` mode. Consumers that only ask "is this gated?" (admin
26
+ * dashboard, MCP) keep using this and need no knowledge of credentials. */
24
27
  hasPassword: z.boolean(),
25
28
  isDefault: z.boolean(),
29
+ /** Named sign-ins pointing at this audience. Always 0 in `.env` mode, which
30
+ * has no credentials — use `hasPassword` there. */
31
+ credentialCount: z.number().int().nonnegative().default(0),
26
32
  });
27
33
 
28
34
  export type Session = z.infer<typeof SessionSchema>;
@@ -26,7 +26,7 @@ export type SectionMeta = z.infer<typeof SectionMetaSchema>;
26
26
  // add its first segment here (the test in Step 1 guards the known set). This is
27
27
  // the single source of truth for reserved slugs; the schema, uniquePageSlug, and
28
28
  // /api/save all import it.
29
- export const RESERVED_SLUGS = ["edit", "api", "login", "set-password", "404", "present", "print"] as const;
29
+ export const RESERVED_SLUGS = ["edit", "api", "login", "set-password", "404", "present", "print", "audiences"] as const;
30
30
 
31
31
  // Subset of RESERVED_SLUGS the index schema hard-rejects at parse time. Only
32
32
  // slugs reserved since before client sites existed may hard-fail here: parse-
@@ -707,6 +707,54 @@ export type Database = {
707
707
  },
708
708
  ]
709
709
  }
710
+ viewer_credentials: {
711
+ Row: {
712
+ audience_id: string
713
+ created_at: string
714
+ id: string
715
+ label: string | null
716
+ password_cipher: string | null
717
+ password_hash: string
718
+ site_id: string
719
+ username: string
720
+ }
721
+ Insert: {
722
+ audience_id: string
723
+ created_at?: string
724
+ id?: string
725
+ label?: string | null
726
+ password_cipher?: string | null
727
+ password_hash: string
728
+ site_id: string
729
+ username: string
730
+ }
731
+ Update: {
732
+ audience_id?: string
733
+ created_at?: string
734
+ id?: string
735
+ label?: string | null
736
+ password_cipher?: string | null
737
+ password_hash?: string
738
+ site_id?: string
739
+ username?: string
740
+ }
741
+ Relationships: [
742
+ {
743
+ foreignKeyName: "viewer_credentials_audience_id_fkey"
744
+ columns: ["audience_id"]
745
+ isOneToOne: false
746
+ referencedRelation: "viewer_audiences"
747
+ referencedColumns: ["id"]
748
+ },
749
+ {
750
+ foreignKeyName: "viewer_credentials_site_id_fkey"
751
+ columns: ["site_id"]
752
+ isOneToOne: false
753
+ referencedRelation: "sites"
754
+ referencedColumns: ["id"]
755
+ },
756
+ ]
757
+ }
710
758
  }
711
759
  Views: {
712
760
  [_ in never]: never
@@ -722,6 +770,12 @@ export type Database = {
722
770
  site_id: string
723
771
  user_id: string
724
772
  }[]
773
+ SetofOptions: {
774
+ from: "*"
775
+ to: "editor_locks"
776
+ isOneToOne: false
777
+ isSetofReturn: true
778
+ }
725
779
  }
726
780
  chat_usage_hit: {
727
781
  Args: {
@@ -737,9 +791,9 @@ export type Database = {
737
791
  get_site_users_with_status: {
738
792
  Args: { p_site_id: string }
739
793
  Returns: {
740
- created_at: string | null
794
+ created_at: string
741
795
  email: string
742
- last_sign_in_at: string | null
796
+ last_sign_in_at: string
743
797
  role: string
744
798
  user_id: string
745
799
  }[]
@@ -767,10 +821,10 @@ export type Database = {
767
821
  p_token_hash: string
768
822
  }
769
823
  Returns: {
824
+ client_id: string
825
+ family_id: string
826
+ platform_user_id: string
770
827
  status: string
771
- family_id: string | null
772
- client_id: string | null
773
- platform_user_id: string | null
774
828
  }[]
775
829
  }
776
830
  provision_claim: { Args: { p_site_id: string }; Returns: boolean }
@@ -1,30 +0,0 @@
1
- import {
2
- HexColorSchema
3
- } from "./chunk-BGHWUJFV.js";
4
-
5
- // src/schemas/audience.ts
6
- import { z } from "zod";
7
- var AudienceNameSchema = z.string().min(1).max(32).regex(
8
- /^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$/,
9
- "lowercase alphanumeric, dashes and underscores allowed"
10
- );
11
- var AudienceColorSchema = HexColorSchema.nullable();
12
- function slugifyAudienceName(input) {
13
- return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
14
- }
15
-
16
- // src/schemas/media-grid-options.ts
17
- import { z as z2 } from "zod";
18
- var MediaGridOptionsSchema = z2.object({
19
- square: z2.boolean().optional(),
20
- border: z2.boolean().optional(),
21
- crop: z2.boolean().optional(),
22
- showCaptions: z2.boolean().optional()
23
- }).default({});
24
-
25
- export {
26
- AudienceNameSchema,
27
- AudienceColorSchema,
28
- slugifyAudienceName,
29
- MediaGridOptionsSchema
30
- };