stack-site-builder 1.13.0 โ†’ 1.14.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +51 -0
  3. package/index.mjs +9 -0
  4. package/package.json +1 -1
  5. package/src/components/ArticleCard.astro +9 -3
  6. package/src/components/ArticleLink.astro +7 -2
  7. package/src/components/BlogIndex.astro +2 -0
  8. package/src/components/CategoryIndex.astro +2 -0
  9. package/src/components/CodeSamples.astro +6 -1
  10. package/src/components/ConceptCard.astro +9 -3
  11. package/src/components/ConceptIndex.astro +2 -0
  12. package/src/components/ConceptLink.astro +7 -2
  13. package/src/components/DeckView.astro +17 -2
  14. package/src/components/DetailTabs.astro +6 -1
  15. package/src/components/Home.astro +2 -0
  16. package/src/components/MermaidLoader.astro +3 -1
  17. package/src/components/PricingSection.astro +4 -1
  18. package/src/components/PrivateGate.astro +135 -0
  19. package/src/components/ProjectViewer.astro +24 -13
  20. package/src/components/SlidesIndex.astro +8 -2
  21. package/src/components/StackCard.astro +15 -3
  22. package/src/components/StackDetail.astro +9 -0
  23. package/src/components/TagIndex.astro +2 -0
  24. package/src/components/TocRail.astro +12 -6
  25. package/src/components/VendorIndex.astro +2 -0
  26. package/src/content.ts +21 -0
  27. package/src/i18n/ui.ts +14 -0
  28. package/src/layouts/BaseLayout.astro +10 -4
  29. package/src/lib/private-client.ts +160 -0
  30. package/src/lib/private.ts +129 -0
  31. package/src/lib/reinit.ts +12 -0
  32. package/src/pages/[...lang]/[page].astro +7 -2
  33. package/src/pages/[...lang]/article/[...id].astro +7 -2
  34. package/src/pages/[...lang]/concept/[...id].astro +7 -2
  35. package/src/pages/[...lang]/stack/[...id].astro +7 -2
@@ -26,9 +26,12 @@ interface Props {
26
26
  repo?: string;
27
27
  version?: string;
28
28
  deprecated?: boolean;
29
+ /** Private entry: show a lock and the (public) `teaser` instead of the description. */
30
+ isPrivate?: boolean;
31
+ teaser?: string;
29
32
  }
30
33
 
31
- const { slug, lang, name, formerNames = [], vendor, description, tags, pricing, license, language, category, logo, logoDark, repo, version, deprecated } =
34
+ const { slug, lang, name, formerNames = [], vendor, description, tags, pricing, license, language, category, logo, logoDark, repo, version, deprecated, isPrivate = false, teaser } =
32
35
  Astro.props;
33
36
  const t = useTranslations(lang);
34
37
  const href = getRelativeLocaleUrl(lang, `stack/${slug}/`);
@@ -54,7 +57,11 @@ const langTokens = (language ?? '')
54
57
  .split('/')
55
58
  .map((s) => s.trim())
56
59
  .filter(Boolean);
57
- const searchText = `${name} ${description} ${tags.join(' ')}`.toLowerCase();
60
+ // Private entries: the card carries only public copy โ€” the title and the
61
+ // authored `teaser` (description stays inside the encrypted detail page), and
62
+ // the search index must not include the hidden description either.
63
+ const shownDesc = isPrivate ? (teaser ?? '') : description;
64
+ const searchText = `${name} ${shownDesc} ${tags.join(' ')}`.toLowerCase();
58
65
  ---
59
66
 
60
67
  {/* The card is a container, not a link: the name is the stretched primary link
@@ -85,6 +92,11 @@ const searchText = `${name} ${description} ${tags.join(' ')}`.toLowerCase();
85
92
  <div class="min-w-0">
86
93
  <h3 class="truncate text-base font-semibold text-[var(--aas-text)]">
87
94
  <a href={href} class="no-underline after:absolute after:inset-0 after:content-['']">
95
+ {isPrivate && (
96
+ <span aria-label={t('private.badge')} title={t('private.badge')}>
97
+ ๐Ÿ”’{' '}
98
+ </span>
99
+ )}
88
100
  {name}
89
101
  </a>
90
102
  </h3>
@@ -100,7 +112,7 @@ const searchText = `${name} ${description} ${tags.join(' ')}`.toLowerCase();
100
112
  <p
101
113
  class="aas-md mt-2 text-sm leading-relaxed text-[var(--aas-muted)]"
102
114
  data-card-desc
103
- set:html={inlineMd(description)}
115
+ set:html={inlineMd(shownDesc)}
104
116
  />
105
117
 
106
118
  {/* Secondary facts: version ยท release date ยท language ยท license.
@@ -402,8 +402,16 @@ const relatedToolsByFolder = new Map(projectRail.map((p) => [p.folder, p.tools])
402
402
 
403
403
  <script>
404
404
  import { startScrollSpy, initBackToTop, initReveals } from '../lib/toc-rail-client';
405
+ import { initOnReady } from '../lib/reinit';
405
406
 
407
+ // Wrapped in initOnReady: on a private tool page all of this DOM sits inside
408
+ // the encrypted body, so the load-time run wires nothing (null rail, empty
409
+ // lists โ€” its document listeners are inert) and the post-decryption run does
410
+ // the real wiring against the injected DOM.
411
+ initOnReady(() => {
406
412
  const rail = document.querySelector<HTMLElement>('[data-toc-rail]');
413
+ if (rail?.dataset.aasInit) return;
414
+ if (rail) rail.dataset.aasInit = '1';
407
415
  // Scroll-spy + back-to-top + prose-<details> persistence, shared with
408
416
  // TocRail.astro. The tab/version/example handlers below re-run the spy after
409
417
  // changing which headings are visible.
@@ -481,6 +489,7 @@ const relatedToolsByFolder = new Map(projectRail.map((p) => [p.folder, p.tools])
481
489
  });
482
490
  }),
483
491
  );
492
+ });
484
493
  </script>
485
494
 
486
495
  {
@@ -45,6 +45,8 @@ const entries = (await getStacksByTag(lang, tag)).sort((a, b) =>
45
45
  repo={e.data.repo}
46
46
  version={e.data.version}
47
47
  deprecated={e.data.deprecated}
48
+ isPrivate={e.data.private}
49
+ teaser={e.data.teaser}
48
50
  />
49
51
  ))
50
52
  }
@@ -64,11 +64,17 @@ const hasContent = items.length > 0 || relatedTools.length > 0 || Boolean(projec
64
64
 
65
65
  <script>
66
66
  import { startScrollSpy, initBackToTop, initReveals } from '../lib/toc-rail-client';
67
+ import { initOnReady } from '../lib/reinit';
67
68
 
68
- const rail = document.querySelector<HTMLElement>('[data-toc-rail]');
69
- if (rail) {
70
- startScrollSpy(rail);
71
- initBackToTop(rail);
72
- initReveals(rail);
73
- }
69
+ // Re-runs after private-content decryption (the rail ships inside the
70
+ // encrypted body); the dataset flag keeps a live rail from double-wiring.
71
+ initOnReady(() => {
72
+ const rail = document.querySelector<HTMLElement>('[data-toc-rail]');
73
+ if (rail && !rail.dataset.aasInit) {
74
+ rail.dataset.aasInit = '1';
75
+ startScrollSpy(rail);
76
+ initBackToTop(rail);
77
+ initReveals(rail);
78
+ }
79
+ });
74
80
  </script>
@@ -45,6 +45,8 @@ const vendorName = entries[0]?.data.vendor ?? vendor;
45
45
  repo={e.data.repo}
46
46
  version={e.data.version}
47
47
  deprecated={e.data.deprecated}
48
+ isPrivate={e.data.private}
49
+ teaser={e.data.teaser}
48
50
  />
49
51
  ))
50
52
  }
package/src/content.ts CHANGED
@@ -107,6 +107,11 @@ export function defineAasCollections({ categoryMap }: { categoryMap: Map<string,
107
107
  )
108
108
  .default([]),
109
109
  featured: z.boolean().default(false),
110
+ // Login-gated entry: the body ships encrypted and listings show only the
111
+ // title (+ `teaser`, an explicitly PUBLIC one-liner written for the gate).
112
+ // See docs/private-content-design.md. Requires the AAS_PRIVATE_* env vars.
113
+ private: z.boolean().default(false),
114
+ teaser: z.string().optional(),
110
115
  }),
111
116
  });
112
117
 
@@ -147,6 +152,10 @@ export function defineAasCollections({ categoryMap }: { categoryMap: Map<string,
147
152
  .optional(),
148
153
  tags: z.array(z.string()).default([]),
149
154
  draft: z.boolean().default(false),
155
+ // Login-gated entry (encrypted body; listings show title + optional
156
+ // PUBLIC `teaser`). See docs/private-content-design.md.
157
+ private: z.boolean().default(false),
158
+ teaser: z.string().optional(),
150
159
  }),
151
160
  });
152
161
 
@@ -181,6 +190,10 @@ export function defineAasCollections({ categoryMap }: { categoryMap: Map<string,
181
190
  tags: z.array(z.string()).default([]),
182
191
  order: z.number().optional(), // manual sort on the index (lower first)
183
192
  draft: z.boolean().default(false),
193
+ // Login-gated entry (encrypted body; listings show title + optional
194
+ // PUBLIC `teaser`). See docs/private-content-design.md.
195
+ private: z.boolean().default(false),
196
+ teaser: z.string().optional(),
184
197
  }),
185
198
  });
186
199
 
@@ -221,6 +234,10 @@ export function defineAasCollections({ categoryMap }: { categoryMap: Map<string,
221
234
  toc_level: z.number().int().min(2).max(4).default(2),
222
235
  toc_open: z.boolean().default(true),
223
236
  draft: z.boolean().default(false),
237
+ // Login-gated deck (encrypted body; the slides index shows title +
238
+ // optional PUBLIC `teaser`). See docs/private-content-design.md.
239
+ private: z.boolean().default(false),
240
+ teaser: z.string().optional(),
224
241
  }),
225
242
  });
226
243
 
@@ -249,6 +266,10 @@ export function defineAasCollections({ categoryMap }: { categoryMap: Map<string,
249
266
  navLabel: z.string().optional(),
250
267
  order: z.number().default(0),
251
268
  draft: z.boolean().default(false),
269
+ // Login-gated page (encrypted body; the nav/meta show title + optional
270
+ // PUBLIC `teaser`). See docs/private-content-design.md.
271
+ private: z.boolean().default(false),
272
+ teaser: z.string().optional(),
252
273
  }),
253
274
  });
254
275
 
package/src/i18n/ui.ts CHANGED
@@ -67,6 +67,13 @@ export const ui = {
67
67
  'nav.menu': 'Menu',
68
68
  'code.copy': 'Copy code',
69
69
  'code.copied': 'Copied',
70
+ 'private.badge': 'Private',
71
+ 'private.locked': 'This content is private. Log in to view it.',
72
+ 'private.id': 'ID',
73
+ 'private.password': 'Password',
74
+ 'private.submit': 'Unlock',
75
+ 'private.error': 'Wrong ID or password.',
76
+ 'private.logout': 'Log out',
70
77
  'slides.title': 'Slides',
71
78
  'slides.tagline': 'Concept decks โ€” the same ideas as the concept pages, in slide form.',
72
79
  'slides.deck': 'Deck',
@@ -209,6 +216,13 @@ export const ui = {
209
216
  'nav.menu': '๋ฉ”๋‰ด',
210
217
  'code.copy': '์ฝ”๋“œ ๋ณต์‚ฌ',
211
218
  'code.copied': '๋ณต์‚ฌ๋จ',
219
+ 'private.badge': '๋น„๊ณต๊ฐœ',
220
+ 'private.locked': '๋น„๊ณต๊ฐœ ์ฝ˜ํ…์ธ ์ž…๋‹ˆ๋‹ค. ๋กœ๊ทธ์ธ ํ›„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.',
221
+ 'private.id': '์•„์ด๋””',
222
+ 'private.password': '๋น„๋ฐ€๋ฒˆํ˜ธ',
223
+ 'private.submit': '์ž ๊ธˆ ํ•ด์ œ',
224
+ 'private.error': '์•„์ด๋”” ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.',
225
+ 'private.logout': '๋กœ๊ทธ์•„์›ƒ',
212
226
  'slides.title': '์Šฌ๋ผ์ด๋“œ',
213
227
  'slides.tagline': '๊ฐœ๋… ๋ฑ โ€” ๊ฐœ๋… ํŽ˜์ด์ง€์™€ ๊ฐ™์€ ๋‚ด์šฉ์„ ์Šฌ๋ผ์ด๋“œ๋กœ ์˜ฎ๊ฒผ์Šต๋‹ˆ๋‹ค.',
214
228
  'slides.deck': '๋ฑ',
@@ -17,9 +17,11 @@ interface Props {
17
17
  path: string;
18
18
  /** Wider container โ€” used by detail pages that have a right-rail TOC. */
19
19
  wide?: boolean;
20
+ /** Private entries: ask crawlers not to index the gate page. */
21
+ noindex?: boolean;
20
22
  }
21
23
 
22
- const { title, lang, path, wide = false } = Astro.props;
24
+ const { title, lang, path, wide = false, noindex = false } = Astro.props;
23
25
  const t = useTranslations(lang);
24
26
  // Collapse any authored line breaks (used for on-page wrapping) to a single
25
27
  // space so the SEO meta description stays one clean line.
@@ -113,6 +115,7 @@ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(it
113
115
  <meta name="generator" content={Astro.generator} />
114
116
  <title>{title}</title>
115
117
  <meta name="description" content={description} />
118
+ {noindex && <meta name="robots" content="noindex" />}
116
119
  <link rel="icon" type="image/svg+xml" href={`${base.replace(/\/$/, '')}/favicon.svg`} />
117
120
  {/* On the root (default-locale) home, send first-time visitors to the
118
121
  locale matching their browser โ€” unless they've already picked one (the
@@ -262,6 +265,8 @@ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(it
262
265
  <BackToTop lang={lang} />
263
266
 
264
267
  <script>
268
+ import { initOnReady } from '../lib/reinit';
269
+
265
270
  // Clicking a heading's "#" anchor copies that section's URL to the
266
271
  // clipboard (and updates the address bar) instead of just navigating.
267
272
  document.addEventListener('click', (e) => {
@@ -279,8 +284,9 @@ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(it
279
284
  // Mount a copy-to-clipboard button on every code block: README/overview
280
285
  // prose (.prose pre), the file viewer (.aas-projfile), and the Code tab
281
286
  // (.aas-code). The button is pinned to a non-scrolling host so it stays in
282
- // place on long, horizontally-scrolling code.
283
- (function mountCopyButtons() {
287
+ // place on long, horizontally-scrolling code. Re-runs after private-content
288
+ // decryption (mount() skips hosts that already have a button).
289
+ initOnReady(function mountCopyButtons() {
284
290
  const LABEL = document.documentElement.dataset.copyLabel || 'Copy code';
285
291
  const DONE = document.documentElement.dataset.copiedLabel || 'Copied';
286
292
  const COPY_ICON =
@@ -334,7 +340,7 @@ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(it
334
340
  const fig = el.closest('figure');
335
341
  mount(el, (fig as HTMLElement) ?? wrap(el));
336
342
  });
337
- })();
343
+ });
338
344
 
339
345
  // Close any open <details> dropdown when clicking outside it (or on Esc).
340
346
  // Only nav dropdowns โ€” leave file-tree folders (.aas-tree-dir) and prose
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Browser half of private content: login (PBKDF2 โ†’ unwrap the site key),
3
+ * session caching in localStorage, AES-GCM decryption and DOM injection.
4
+ * Crypto parameters must match src/lib/private.ts. Loaded only by PrivateGate.
5
+ */
6
+ import { PRIVATE_DECRYPTED_EVENT } from './reinit';
7
+
8
+ const KDF_ITERATIONS = 600_000;
9
+ const STORE = 'aas:pk';
10
+
11
+ interface GateData {
12
+ iv: string;
13
+ ct: string;
14
+ users: { h: string; s: string; iv: string; w: string }[];
15
+ salt: string; // build salt for id hashing, base64
16
+ days: number; // session lifetime (0 = never expires)
17
+ }
18
+
19
+ const dec = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
20
+ const enc = (b: ArrayBuffer | Uint8Array) => btoa(String.fromCharCode(...new Uint8Array(b)));
21
+
22
+ async function sha256Hex(bytes: Uint8Array): Promise<string> {
23
+ const d = await crypto.subtle.digest('SHA-256', bytes as BufferSource);
24
+ return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, '0')).join('');
25
+ }
26
+
27
+ async function deriveKek(password: string, salt: Uint8Array): Promise<CryptoKey> {
28
+ const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, [
29
+ 'deriveKey',
30
+ ]);
31
+ return crypto.subtle.deriveKey(
32
+ { name: 'PBKDF2', hash: 'SHA-256', salt: salt as BufferSource, iterations: KDF_ITERATIONS },
33
+ base,
34
+ { name: 'AES-GCM', length: 256 },
35
+ false,
36
+ ['decrypt'],
37
+ );
38
+ }
39
+
40
+ async function aesDecrypt(keyBytes: Uint8Array | CryptoKey, iv: Uint8Array, data: Uint8Array): Promise<ArrayBuffer> {
41
+ const key =
42
+ keyBytes instanceof CryptoKey
43
+ ? keyBytes
44
+ : await crypto.subtle.importKey('raw', keyBytes as BufferSource, 'AES-GCM', false, ['decrypt']);
45
+ return crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv as BufferSource }, key, data as BufferSource);
46
+ }
47
+
48
+ function storedKey(days: number): Uint8Array | null {
49
+ try {
50
+ const raw = localStorage.getItem(STORE);
51
+ if (!raw) return null;
52
+ const { k, t } = JSON.parse(raw) as { k: string; t: number };
53
+ if (days > 0 && Date.now() - t > days * 86_400_000) {
54
+ localStorage.removeItem(STORE);
55
+ return null;
56
+ }
57
+ return dec(k);
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ function clearSession(): void {
64
+ try {
65
+ localStorage.removeItem(STORE);
66
+ } catch {
67
+ /* ignore */
68
+ }
69
+ }
70
+
71
+ /** Swap the gate for the decrypted HTML; re-run inline scripts; notify re-init hooks. */
72
+ function inject(gate: HTMLElement, html: string, logoutLabel: string): void {
73
+ const host = document.createElement('div');
74
+ host.innerHTML = html;
75
+ // innerHTML-injected <script> tags don't execute โ€” recreate each one so
76
+ // classic/inline scripts run (hoisted module scripts re-init via the event).
77
+ host.querySelectorAll('script').forEach((old) => {
78
+ const s = document.createElement('script');
79
+ for (const a of old.attributes) s.setAttribute(a.name, a.value);
80
+ s.textContent = old.textContent;
81
+ old.replaceWith(s);
82
+ });
83
+ // A discreet logout control above the content.
84
+ const bar = document.createElement('div');
85
+ bar.className = 'aas-private-bar';
86
+ const btn = document.createElement('button');
87
+ btn.type = 'button';
88
+ btn.className = 'aas-private-logout';
89
+ btn.textContent = `\u{1F513} ${logoutLabel}`;
90
+ btn.addEventListener('click', () => {
91
+ clearSession();
92
+ location.reload();
93
+ });
94
+ bar.appendChild(btn);
95
+ gate.replaceWith(bar, ...host.childNodes);
96
+ document.dispatchEvent(new CustomEvent(PRIVATE_DECRYPTED_EVENT));
97
+ }
98
+
99
+ async function tryDecrypt(gate: HTMLElement, data: GateData, keyBytes: Uint8Array, logoutLabel: string): Promise<boolean> {
100
+ try {
101
+ const html = new TextDecoder().decode(await aesDecrypt(keyBytes, dec(data.iv), dec(data.ct)));
102
+ inject(gate, html, logoutLabel);
103
+ return true;
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ /** Wire one gate element (PrivateGate renders exactly one per page). */
110
+ export async function mountGate(gate: HTMLElement): Promise<void> {
111
+ const data = JSON.parse(gate.querySelector('[data-private-data]')!.textContent!) as GateData;
112
+ const logoutLabel = gate.dataset.logoutLabel ?? 'Log out';
113
+
114
+ // Already logged in on this device โ†’ decrypt with no form flash. A failure
115
+ // means the master secret rotated: drop the stale key and show the form.
116
+ const cachedK = storedKey(data.days);
117
+ if (cachedK && (await tryDecrypt(gate, data, cachedK, logoutLabel))) return;
118
+ if (cachedK) clearSession();
119
+ gate.querySelector<HTMLElement>('[data-private-form]')!.hidden = false;
120
+
121
+ const form = gate.querySelector('form')!;
122
+ const error = gate.querySelector<HTMLElement>('[data-private-error]')!;
123
+ form.addEventListener('submit', async (e) => {
124
+ e.preventDefault();
125
+ error.hidden = true;
126
+ const idInput = form.querySelector<HTMLInputElement>('input[name="id"]')!;
127
+ const pwInput = form.querySelector<HTMLInputElement>('input[name="password"]')!;
128
+ const button = form.querySelector<HTMLButtonElement>('button[type="submit"]')!;
129
+ button.disabled = true;
130
+ try {
131
+ const id = idInput.value.trim().toLowerCase();
132
+ const salt = dec(data.salt);
133
+ const idBytes = new TextEncoder().encode(id);
134
+ const joined = new Uint8Array(salt.length + idBytes.length);
135
+ joined.set(salt);
136
+ joined.set(idBytes, salt.length);
137
+ const h = await sha256Hex(joined);
138
+ const user = data.users.find((u) => u.h === h);
139
+ if (user) {
140
+ const kek = await deriveKek(pwInput.value, dec(user.s));
141
+ try {
142
+ const k = new Uint8Array(await aesDecrypt(kek, dec(user.iv), dec(user.w)));
143
+ if (await tryDecrypt(gate, data, k, logoutLabel)) {
144
+ try {
145
+ localStorage.setItem(STORE, JSON.stringify({ k: enc(k), t: Date.now() }));
146
+ } catch {
147
+ /* private browsing โ€” session just won't persist */
148
+ }
149
+ return;
150
+ }
151
+ } catch {
152
+ /* wrong password (unwrap failed) โ€” fall through to the error */
153
+ }
154
+ }
155
+ error.hidden = false;
156
+ } finally {
157
+ button.disabled = false;
158
+ }
159
+ });
160
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Build-side half of private (login-gated) content. Runs only during
3
+ * `astro build` / `astro dev` (Node): derives the site content key, wraps it
4
+ * per user, and encrypts rendered HTML. The browser half (login + decrypt)
5
+ * lives in ./private-client.ts; the whole design is documented in
6
+ * docs/private-content-design.md.
7
+ *
8
+ * Env contract (see .env.sample in a consuming site):
9
+ * AAS_PRIVATE_USERS "id:password,id2:password2" (plaintext passwords)
10
+ * AAS_PRIVATE_MASTER_SECRET stable secret K derives from; rotate = global logout
11
+ * AAS_PRIVATE_SESSION_DAYS client session lifetime in days (0 = never; default 30)
12
+ */
13
+ import { createCipheriv, createHash, hkdfSync, pbkdf2Sync, randomBytes } from 'node:crypto';
14
+
15
+ /** Must match private-client.ts. */
16
+ export const KDF_ITERATIONS = 600_000;
17
+ const HKDF_INFO = 'aas-private-v1';
18
+ const MIN_PASSWORD_LENGTH = 10;
19
+
20
+ export interface PrivateUserRecord {
21
+ /** SHA-256(buildSalt + lowercase(id)), hex โ€” the deployed site never carries raw ids. */
22
+ h: string;
23
+ /** PBKDF2 salt, base64. */
24
+ s: string;
25
+ /** AES-GCM IV for the wrapped key, base64. */
26
+ iv: string;
27
+ /** wrappedK = AES-256-GCM(KEK, K) with auth tag appended, base64. */
28
+ w: string;
29
+ }
30
+
31
+ const b64 = (b: Buffer | Uint8Array) => Buffer.from(b).toString('base64');
32
+
33
+ interface PrivateConfig {
34
+ key: Buffer; // K โ€” the site content key
35
+ users: PrivateUserRecord[];
36
+ buildSalt: string; // base64; used by the client to hash the entered id
37
+ sessionDays: number;
38
+ }
39
+
40
+ let cached: PrivateConfig | null = null;
41
+
42
+ /**
43
+ * Parse env + derive keys, once per build. Throws (failing the build loudly)
44
+ * when private entries exist but the env contract isn't met โ€” a misconfigured
45
+ * build must not ship silently locked-forever pages.
46
+ */
47
+ function getConfig(): PrivateConfig {
48
+ if (cached) return cached;
49
+
50
+ const secret = process.env.AAS_PRIVATE_MASTER_SECRET?.trim();
51
+ const usersRaw = process.env.AAS_PRIVATE_USERS?.trim();
52
+ if (!secret || !usersRaw) {
53
+ throw new Error(
54
+ '[private] this site has `private: true` entries, but AAS_PRIVATE_MASTER_SECRET ' +
55
+ 'and/or AAS_PRIVATE_USERS is not set. Set both in .env (locally) or as CI ' +
56
+ 'secrets โ€” see docs/private-content-design.md.',
57
+ );
58
+ }
59
+
60
+ // K derives from the master secret (not random): ordinary redeploys keep
61
+ // logged-in devices working; rotating the secret is the global-logout switch.
62
+ const key = Buffer.from(hkdfSync('sha256', secret, 'aas-private', HKDF_INFO, 32));
63
+
64
+ const buildSaltBytes = randomBytes(16);
65
+ const users: PrivateUserRecord[] = [];
66
+ const seen = new Set<string>();
67
+ for (const pair of usersRaw.split(',')) {
68
+ const idx = pair.indexOf(':');
69
+ if (idx < 1) throw new Error(`[private] AAS_PRIVATE_USERS entry is not "id:password": "${pair.trim()}"`);
70
+ const id = pair.slice(0, idx).trim().toLowerCase();
71
+ const password = pair.slice(idx + 1).trim();
72
+ if (seen.has(id)) throw new Error(`[private] duplicate user id "${id}" in AAS_PRIVATE_USERS`);
73
+ seen.add(id);
74
+ if (password.length < MIN_PASSWORD_LENGTH)
75
+ throw new Error(
76
+ `[private] password for user "${id}" is shorter than ${MIN_PASSWORD_LENGTH} chars โ€” ` +
77
+ 'wrapped keys can be brute-forced offline, use a long passphrase',
78
+ );
79
+ const salt = randomBytes(16);
80
+ const kek = pbkdf2Sync(password, salt, KDF_ITERATIONS, 32, 'sha256');
81
+ const iv = randomBytes(12);
82
+ const cipher = createCipheriv('aes-256-gcm', kek, iv);
83
+ const wrapped = Buffer.concat([cipher.update(key), cipher.final(), cipher.getAuthTag()]);
84
+ users.push({
85
+ h: createHash('sha256').update(Buffer.concat([buildSaltBytes, Buffer.from(id)])).digest('hex'),
86
+ s: b64(salt),
87
+ iv: b64(iv),
88
+ w: b64(wrapped),
89
+ });
90
+ }
91
+
92
+ const daysRaw = process.env.AAS_PRIVATE_SESSION_DAYS?.trim();
93
+ const sessionDays = daysRaw === undefined || daysRaw === '' ? 30 : Number(daysRaw);
94
+ if (!Number.isFinite(sessionDays) || sessionDays < 0)
95
+ throw new Error(`[private] AAS_PRIVATE_SESSION_DAYS must be a number โ‰ฅ 0 (got "${daysRaw}")`);
96
+
97
+ console.warn(
98
+ '[private] this build contains private entries โ€” remember: the SOURCE repo must be ' +
99
+ 'private (the .mdx files are plaintext); only the built output is encrypted.',
100
+ );
101
+
102
+ cached = { key, users, buildSalt: b64(buildSaltBytes), sessionDays };
103
+ return cached;
104
+ }
105
+
106
+ /** Everything PrivateGate embeds for the client (records are ~120 bytes/user). */
107
+ export function privateClientData(): { users: PrivateUserRecord[]; salt: string; days: number } {
108
+ const { users, buildSalt, sessionDays } = getConfig();
109
+ return { users, salt: buildSalt, days: sessionDays };
110
+ }
111
+
112
+ /** AES-256-GCM encrypt rendered HTML with the site content key. */
113
+ export function encryptHtml(html: string): { iv: string; ct: string } {
114
+ const { key } = getConfig();
115
+ const iv = randomBytes(12);
116
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
117
+ const ct = Buffer.concat([cipher.update(html, 'utf8'), cipher.final(), cipher.getAuthTag()]);
118
+ return { iv: b64(iv), ct: b64(ct) };
119
+ }
120
+
121
+ /**
122
+ * Pathname registry for the sitemap filter: PrivateGate records every private
123
+ * page it renders; index.mjs (a separate module graph โ€” hence globalThis) reads
124
+ * it in the sitemap `filter`, which @astrojs/sitemap runs after the pages built.
125
+ */
126
+ export function registerPrivatePath(pathname: string): void {
127
+ const g = globalThis as { __aasPrivatePaths?: Set<string> };
128
+ (g.__aasPrivatePaths ??= new Set()).add(pathname.replace(/\/?$/, '/'));
129
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Run a DOM-wiring routine now AND again whenever private content is decrypted
3
+ * and injected (the injected HTML wasn't there when page scripts first ran).
4
+ * Component scripts whose targets can sit inside a private gate wrap their init
5
+ * in this. `fn` must be idempotent or guard against double-wiring itself.
6
+ */
7
+ export const PRIVATE_DECRYPTED_EVENT = 'aas:private-decrypted';
8
+
9
+ export function initOnReady(fn: () => void): void {
10
+ fn();
11
+ document.addEventListener(PRIVATE_DECRYPTED_EVENT, () => fn());
12
+ }
@@ -3,6 +3,7 @@ import { site } from '@aas-data/site';
3
3
  import type { GetStaticPaths } from 'astro';
4
4
  import BaseLayout from '../../layouts/BaseLayout.astro';
5
5
  import PageDetail from '../../components/PageDetail.astro';
6
+ import PrivateGate from '../../components/PrivateGate.astro';
6
7
  import { getPages, pageSlugOf } from '../../lib/pages';
7
8
  import { allLocales, langParam } from '../../lib/locales';
8
9
 
@@ -18,13 +19,17 @@ export const getStaticPaths = (async () => {
18
19
 
19
20
  const { lang, entry } = Astro.props;
20
21
  const slug = pageSlugOf(entry);
22
+ const priv = entry.data.private;
21
23
  ---
22
24
 
23
25
  <BaseLayout
24
26
  title={`${entry.data.title} โ€” ${site.name}`}
25
- description={entry.data.description}
27
+ description={priv ? entry.data.teaser : entry.data.description}
26
28
  lang={lang}
27
29
  path={`${slug}/`}
30
+ noindex={priv}
28
31
  >
29
- <PageDetail entry={entry} lang={lang} />
32
+ <PrivateGate enabled={priv} lang={lang} title={entry.data.title} teaser={entry.data.teaser}>
33
+ <PageDetail entry={entry} lang={lang} />
34
+ </PrivateGate>
30
35
  </BaseLayout>
@@ -3,6 +3,7 @@ import { site } from '@aas-data/site';
3
3
  import type { GetStaticPaths } from 'astro';
4
4
  import BaseLayout from '../../../layouts/BaseLayout.astro';
5
5
  import ArticleDetail from '../../../components/ArticleDetail.astro';
6
+ import PrivateGate from '../../../components/PrivateGate.astro';
6
7
  import { getArticles, articleSlugOf } from '../../../lib/articles';
7
8
  import { allLocales, langParam } from '../../../lib/locales';
8
9
 
@@ -18,13 +19,17 @@ export const getStaticPaths = (async () => {
18
19
 
19
20
  const { lang, entry } = Astro.props;
20
21
  const slug = articleSlugOf(entry);
22
+ const priv = entry.data.private;
21
23
  ---
22
24
 
23
25
  <BaseLayout
24
26
  title={`${entry.data.title} โ€” ${site.name}`}
25
- description={entry.data.description}
27
+ description={priv ? entry.data.teaser : entry.data.description}
26
28
  lang={lang}
27
29
  path={`article/${slug}/`}
30
+ noindex={priv}
28
31
  >
29
- <ArticleDetail entry={entry} lang={lang} />
32
+ <PrivateGate enabled={priv} lang={lang} title={entry.data.title} teaser={entry.data.teaser}>
33
+ <ArticleDetail entry={entry} lang={lang} />
34
+ </PrivateGate>
30
35
  </BaseLayout>
@@ -3,6 +3,7 @@ import { site } from '@aas-data/site';
3
3
  import type { GetStaticPaths } from 'astro';
4
4
  import BaseLayout from '../../../layouts/BaseLayout.astro';
5
5
  import ConceptDetail from '../../../components/ConceptDetail.astro';
6
+ import PrivateGate from '../../../components/PrivateGate.astro';
6
7
  import { getConcepts, conceptSlugOf } from '../../../lib/concepts';
7
8
  import { allLocales, langParam } from '../../../lib/locales';
8
9
 
@@ -18,13 +19,17 @@ export const getStaticPaths = (async () => {
18
19
 
19
20
  const { lang, entry } = Astro.props;
20
21
  const slug = conceptSlugOf(entry);
22
+ const priv = entry.data.private;
21
23
  ---
22
24
 
23
25
  <BaseLayout
24
26
  title={`${entry.data.title} โ€” ${site.name}`}
25
- description={entry.data.description}
27
+ description={priv ? entry.data.teaser : entry.data.description}
26
28
  lang={lang}
27
29
  path={`concept/${slug}/`}
30
+ noindex={priv}
28
31
  >
29
- <ConceptDetail entry={entry} lang={lang} />
32
+ <PrivateGate enabled={priv} lang={lang} title={entry.data.title} teaser={entry.data.teaser}>
33
+ <ConceptDetail entry={entry} lang={lang} />
34
+ </PrivateGate>
30
35
  </BaseLayout>