@warpgogol/werkstatt-shared 0.8.2 → 0.8.5

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/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # @warpgogol/werkstatt-shared
2
+
3
+ [Українська](README.uk.md) | English
4
+
5
+ Stack-agnostic shared infrastructure extracted from `werkstatt-site` (RFC-0868). Owns checks, integration, ontology, passport, share, and surface domains consumed by both the Werkstatt engine and the site plugin.
6
+
7
+ ---
8
+
9
+ ## What this package does
10
+
11
+ This is a **shared infrastructure library** used by the [Werkstatt](https://www.npmjs.com/package/@warpgogol/werkstatt) engine and the `werkstatt-site` plugin. It provides:
12
+
13
+ - **Checks** — content validators, SEO validators, surface expand/bake logic
14
+ - **Integration** — CRM, funnel, hub, sharding, QStash adapters
15
+ - **Ontology** — catalogs, enums, Sternsystem owner types
16
+ - **Passport** — DHT signing, identity signing, schema validation
17
+ - **Share** — slug generation, semantic extraction, URL canonicalization, route filtering, middleware, access protection
18
+ - **Surface** — surface expand/bake helpers and labels
19
+
20
+ You don't use this package on its own — it is consumed by the engine and site plugin as a dependency.
21
+
22
+ ---
23
+
24
+ ## Installation
25
+
26
+ ```sh
27
+ pnpm add @warpgogol/werkstatt-shared
28
+ ```
29
+
30
+ This package is installed automatically when you install `@warpgogol/werkstatt` or `@warpgogol/werkstatt-site`.
31
+
32
+ ---
33
+
34
+ ## How it fits into the Werkstatt ecosystem
35
+
36
+ | Package | Role |
37
+ | --- | --- |
38
+ | `@warpgogol/forge` | Governance layer — skills, RFC/ADR workflows, CLI, project scaffolding |
39
+ | `@warpgogol/werkstatt` | Runtime engine — missions, releases, deployment, certification, Bordbuch |
40
+ | `@warpgogol/werkstatt-shared` | **This package** — shared infrastructure (checks, integration, ontology, passport, share) |
41
+ | `@warpgogol/werkstatt-site` | Astro site plugin — consumes this package for checks, integration, ontology, passport, share |
42
+
43
+ The engine and site plugin both import from this package. It MUST NOT import from `werkstatt-site` — enforced by `werkstatt.shared.validate`.
44
+
45
+ ---
46
+
47
+ ## Canonical utilities
48
+
49
+ ### Slug generation (RFC-0915, DNA-88)
50
+
51
+ | Export | Purpose |
52
+ | --- | --- |
53
+ | `slugUrl(text, lang?)` | Locale-aware URL slug (German umlauts, Ukrainian transliteration, default) |
54
+ | `slugId(text)` | Semantic block ID slug |
55
+ | `HeadingSlugger` | Stateful heading anchor deduplication |
56
+
57
+ ```ts
58
+ import { slugUrl } from "@warpgogol/werkstatt-shared/share/slug";
59
+
60
+ const url = slugUrl("Über uns", "de"); // "ueber-uns"
61
+ ```
62
+
63
+ ### Semantic extraction (RFC-0901)
64
+
65
+ | Export | Purpose |
66
+ | --- | --- |
67
+ | `splitSentences(text, locale?)` | Locale-aware sentence boundary detection (`de`, `uk`, `en`) |
68
+
69
+ ### Canonical entity URL (RFC-0910)
70
+
71
+ | Export | Purpose |
72
+ | --- | --- |
73
+ | `canonicalRootUrl(baseUrl)` | Unprefixed root URL for JSON-LD entity identity |
74
+
75
+ ### Placeholder route filtering (RFC-0917)
76
+
77
+ | Export | Purpose |
78
+ | --- | --- |
79
+ | `hasPlaceholderRoutes(routes)` | Detect Astro dynamic route templates (`[slug]`, `[version]`) |
80
+
81
+ ---
82
+
83
+ ## Architecture
84
+
85
+ | Directory | Purpose |
86
+ | --- | --- |
87
+ | `src/index.ts` | Main barrel export |
88
+ | `src/checks/` | Content validators, SEO validators, surface expand/bake |
89
+ | `src/integration/` | CRM, funnel, hub, sharding, QStash adapters |
90
+ | `src/ontology/` | Catalogs, enums, Sternsystem owner types |
91
+ | `src/passport/` | DHT signing, identity signing, schema validation |
92
+ | `src/share/` | Slug, semantic, URL canonicalization, routes, middleware, access protection |
93
+ | `src/surface/` | Surface expand/bake helpers and labels |
94
+ | `src/content/` | SystemManifest types and content schema |
95
+
96
+ ---
97
+
98
+ ## Boundary rules
99
+
100
+ - This package MUST NOT import from `@warpgogol/werkstatt-site` — enforced by `werkstatt.shared.validate`.
101
+ - This package MAY import from `@warpgogol/werkstatt` (engine) and external packages.
102
+ - Axiom dependencies (`@syrokomskyi/axiom-*`) are `optionalDependencies` — consumers without axiom installed must use type-only imports or guard runtime access.
103
+
104
+ ---
105
+
106
+ ## Publishing to npm
107
+
108
+ This package is published to the npm registry as `@warpgogol/werkstatt-shared`. Publishing is automated via GitHub Actions CI.
109
+
110
+ ### How it works
111
+
112
+ 1. The source lives in the [warpgogol/werkstatt](https://github.com/syrokomskyi/werkstatt) monorepo under `packages/werkstatt-shared/`.
113
+ 2. [`@warpgogol/repo-extract`](https://github.com/syrokomskyi/repo-extract) extracts the package into the standalone [syrokomskyi/werkstatt-shared](https://github.com/syrokomskyi/werkstatt-shared) repository, flattening it to repo root and stripping workspace dependencies.
114
+ 3. The generated GitHub Actions CI workflow runs on every push to `main`: lint → typecheck → build → test → `npm publish --provenance --access public`.
115
+ 4. The `NPM_TOKEN` secret must be set in the [repository settings](https://github.com/syrokomskyi/werkstatt-shared/settings/secrets/actions).
116
+
117
+ ### Triggering a new release
118
+
119
+ From the werkstatt monorepo root:
120
+
121
+ ```sh
122
+ # 1. Bump the version in packages/werkstatt-shared/package.json
123
+ # 2. Run the extraction (extracts + commits + pushes to github.com:syrokomskyi/werkstatt-shared.git)
124
+ pnpm exec repo-extract --config packages/werkstatt-shared/extract.config.yaml --verbose
125
+
126
+ # 3. CI picks up the push and publishes to npm automatically
127
+ ```
128
+
129
+ After CI completes, verify the new version on [npmjs.com/package/@warpgogol/werkstatt-shared](https://www.npmjs.com/package/@warpgogol/werkstatt-shared).
130
+
131
+ ---
132
+
133
+ ## License
134
+
135
+ Apache-2.0
package/README.uk.md ADDED
@@ -0,0 +1,135 @@
1
+ # @warpgogol/werkstatt-shared
2
+
3
+ Українська | [English](README.md)
4
+
5
+ Спільна інфраструктура, незалежна від стеку, витягнута з `werkstatt-site` (RFC-0868). Володіє доменами checks, integration, ontology, passport, share та surface, які споживають рушій Werkstatt та site-плагін.
6
+
7
+ ---
8
+
9
+ ## Що робить цей пакет
10
+
11
+ Це **бібліотека спільної інфраструктури**, яку використовують рушій [Werkstatt](https://www.npmjs.com/package/@warpgogol/werkstatt) та плагін `werkstatt-site`. Вона надає:
12
+
13
+ - **Checks** — валідатори контенту, SEO-валідатори, логіка surface expand/bake
14
+ - **Integration** — адаптери CRM, funnel, hub, sharding, QStash
15
+ - **Ontology** — каталоги, enum-и, типи Sternsystem owner
16
+ - **Passport** — DHT-підписування, підписування ідентичності, валідація схем
17
+ - **Share** — генерація slug-ів, семантична екстракція, канонізація URL, фільтрація маршрутів, middleware, захист доступу
18
+ - **Surface** — хелпери surface expand/bake та мітки
19
+
20
+ Цей пакет не використовується самостійно — його споживають рушій та site-плагін як залежність.
21
+
22
+ ---
23
+
24
+ ## Встановлення
25
+
26
+ ```sh
27
+ pnpm add @warpgogol/werkstatt-shared
28
+ ```
29
+
30
+ Цей пакет встановлюється автоматично при встановленні `@warpgogol/werkstatt` або `@warpgogol/werkstatt-site`.
31
+
32
+ ---
33
+
34
+ ## Як це вписується в екосистему Werkstatt
35
+
36
+ | Пакет | Роль |
37
+ | --- | --- |
38
+ | `@warpgogol/forge` | Шар управління — навички, RFC/ADR робочі процеси, CLI, скаффолд проєктів |
39
+ | `@warpgogol/werkstatt` | Рушій runtime — місії, релізи, розгортання, сертифікація, Bordbuch |
40
+ | `@warpgogol/werkstatt-shared` | **Цей пакет** — спільна інфраструктура (checks, integration, ontology, passport, share) |
41
+ | `@warpgogol/werkstatt-site` | Astro site-плагін — споживає цей пакет для checks, integration, ontology, passport, share |
42
+
43
+ Рушій та site-плагін імпортують з цього пакунка. Він НЕ ПОВИНЕН імпортувати з `werkstatt-site` — це контролюється `werkstatt.shared.validate`.
44
+
45
+ ---
46
+
47
+ ## Канонічні утиліти
48
+
49
+ ### Генерація slug (RFC-0915, DNA-88)
50
+
51
+ | Експорт | Призначення |
52
+ | --- | --- |
53
+ | `slugUrl(text, lang?)` | Локалізований URL-slug (німецькі умлаути, українська транслітерація, за замовчуванням) |
54
+ | `slugId(text)` | Семантичний slug ID блоку |
55
+ | `HeadingSlugger` | Станковий дедуплікатор якорів заголовків |
56
+
57
+ ```ts
58
+ import { slugUrl } from "@warpgogol/werkstatt-shared/share/slug";
59
+
60
+ const url = slugUrl("Über uns", "de"); // "ueber-uns"
61
+ ```
62
+
63
+ ### Семантична екстракція (RFC-0901)
64
+
65
+ | Експорт | Призначення |
66
+ | --- | --- |
67
+ | `splitSentences(text, locale?)` | Локалізоване виявлення меж речень (`de`, `uk`, `en`) |
68
+
69
+ ### Канонічний URL сутності (RFC-0910)
70
+
71
+ | Експорт | Призначення |
72
+ | --- | --- |
73
+ | `canonicalRootUrl(baseUrl)` | Непрефіксований кореневий URL для JSON-LD ідентичності сутності |
74
+
75
+ ### Фільтрація плейсхолдер-маршрутів (RFC-0917)
76
+
77
+ | Експорт | Призначення |
78
+ | --- | --- |
79
+ | `hasPlaceholderRoutes(routes)` | Виявлення Astro динамічних шаблонів маршрутів (`[slug]`, `[version]`) |
80
+
81
+ ---
82
+
83
+ ## Архітектура
84
+
85
+ | Директорія | Призначення |
86
+ | --- | --- |
87
+ | `src/index.ts` | Головний barrel-експорт |
88
+ | `src/checks/` | Валідатори контенту, SEO-валідатори, surface expand/bake |
89
+ | `src/integration/` | Адаптери CRM, funnel, hub, sharding, QStash |
90
+ | `src/ontology/` | Каталоги, enum-и, типи Sternsystem owner |
91
+ | `src/passport/` | DHT-підписування, підписування ідентичності, валідація схем |
92
+ | `src/share/` | Slug, семантика, канонізація URL, маршрути, middleware, захист доступу |
93
+ | `src/surface/` | Хелпери surface expand/bake та мітки |
94
+ | `src/content/` | Типи SystemManifest та схема контенту |
95
+
96
+ ---
97
+
98
+ ## Межі
99
+
100
+ - Цей пакет НЕ ПОВИНЕН імпортувати з `@warpgogol/werkstatt-site` — контролюється `werkstatt.shared.validate`.
101
+ - Цей пакет МОЖЕ імпортувати з `@warpgogol/werkstatt` (рушій) та зовнішніх пакунків.
102
+ - Axiom-залежності (`@syrokomskyi/axiom-*`) — `optionalDependencies`. Споживачі без axiom повинні використовувати type-only імпорти або охороняти runtime-доступ.
103
+
104
+ ---
105
+
106
+ ## Публікація в npm
107
+
108
+ Цей пакет публікується в реєстр npm як `@warpgogol/werkstatt-shared`. Публікація автоматизована через GitHub Actions CI.
109
+
110
+ ### Як це працює
111
+
112
+ 1. Вихідний код знаходиться в монорепозиторії [warpgogol/werkstatt](https://github.com/syrokomskyi/werkstatt) у `packages/werkstatt-shared/`.
113
+ 2. [`@warpgogol/repo-extract`](https://github.com/syrokomskyi/repo-extract) витягує пакет у автономний репозиторій [syrokomskyi/werkstatt-shared](https://github.com/syrokomskyi/werkstatt-shared), вирівнюючи його до кореня репозиторію та видаляючи залежності робочого простору.
114
+ 3. Згенерований GitHub Actions CI-воркфлоу запускається при кожному пуші в `main`: lint → typecheck → build → test → `npm publish --provenance --access public`.
115
+ 4. Секрет `NPM_TOKEN` має бути встановлений у [налаштуваннях репозиторію](https://github.com/syrokomskyi/werkstatt-shared/settings/secrets/actions).
116
+
117
+ ### Запуск нового релізу
118
+
119
+ З кореня монорепозиторію werkstatt:
120
+
121
+ ```sh
122
+ # 1. Підняти версію в packages/werkstatt-shared/package.json
123
+ # 2. Запустити екстракцію (витягує + комітить + пушить в github.com:syrokomskyi/werkstatt-shared.git)
124
+ pnpm exec repo-extract --config packages/werkstatt-shared/extract.config.yaml --verbose
125
+
126
+ # 3. CI підхоплює пуш і публікує в npm автоматично
127
+ ```
128
+
129
+ Після завершення CI перевірте нову версію на [npmjs.com/package/@warpgogol/werkstatt-shared](https://www.npmjs.com/package/@warpgogol/werkstatt-shared).
130
+
131
+ ---
132
+
133
+ ## Ліцензія
134
+
135
+ Apache-2.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warpgogol/werkstatt-shared",
3
- "version": "0.8.2",
3
+ "version": "0.8.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -219,6 +219,8 @@ export interface ImageVariantEntry {
219
219
  * stale derived variants (same contract as video.variants.generate RFC-0210).
220
220
  */
221
221
  sourceHash?: string;
222
+ /** RFC-0928: WebP quality used by image.variants.generate (minimum 90). */
223
+ quality?: number;
222
224
  }
223
225
 
224
226
  /** Top-level manifest written by `image.variants.generate` and read by createBuildPortableProvider. */
@@ -45,6 +45,16 @@ function isDevOrAltHost(host: string): boolean {
45
45
  return host.startsWith("dev.") || host.startsWith("alt.");
46
46
  }
47
47
 
48
+ /**
49
+ * Paths exempt from access protection on dev/alt subdomains.
50
+ * Browsers fetch these without sending Basic Auth credentials, causing 401 console errors.
51
+ */
52
+ const PUBLIC_STATIC_EXEMPTIONS = ["/manifest.webmanifest"];
53
+
54
+ function isExemptPath(pathname: string): boolean {
55
+ return PUBLIC_STATIC_EXEMPTIONS.includes(pathname);
56
+ }
57
+
48
58
  /**
49
59
  * RFC-0899: Check access protection for a request. Called from the Worker entry point
50
60
  * (worker.ts) before passing to the Astro handler. This is necessary because Astro
@@ -66,6 +76,9 @@ export function checkAccessProtection(
66
76
  const host = request.headers.get("host") ?? "";
67
77
  if (!isDevOrAltHost(host)) return null;
68
78
 
79
+ const url = new URL(request.url);
80
+ if (isExemptPath(url.pathname)) return null;
81
+
69
82
  const pin = (env.ACCESS_PIN as string | undefined) ?? undefined;
70
83
 
71
84
  // No PIN set — allow access (caller should add X-Robots-Tag)
@@ -144,6 +157,13 @@ export const accessProtectionMiddleware = defineMiddleware(async (context: any,
144
157
  return next();
145
158
  }
146
159
 
160
+ const url = new URL(context.request.url);
161
+ if (isExemptPath(url.pathname)) {
162
+ const response = await next();
163
+ response.headers.set("X-Robots-Tag", NOINDEX_HEADER);
164
+ return response;
165
+ }
166
+
147
167
  const pin = await resolveAccessPin();
148
168
 
149
169
  // No PIN set — allow access but still set noindex headers
@@ -25,7 +25,7 @@ describe("RFC-0899: access protection middleware", () => {
25
25
  ) => Promise<Response>;
26
26
  }
27
27
 
28
- function makeContext(host: string, authHeader?: string) {
28
+ function makeContext(host: string, authHeader?: string, pathname = "/") {
29
29
  const headers = new Map<string, string>();
30
30
  headers.set("host", host);
31
31
  if (authHeader) headers.set("authorization", authHeader);
@@ -34,6 +34,7 @@ describe("RFC-0899: access protection middleware", () => {
34
34
  headers: {
35
35
  get: (name: string) => headers.get(name.toLowerCase()) ?? null,
36
36
  },
37
+ url: `https://${host}${pathname}`,
37
38
  },
38
39
  };
39
40
  }
@@ -42,10 +43,11 @@ describe("RFC-0899: access protection middleware", () => {
42
43
  handler: (context: unknown, next: () => Promise<Response>) => Promise<Response>,
43
44
  host: string,
44
45
  authHeader?: string,
46
+ pathname = "/",
45
47
  ): Promise<Response & { _nextCalled: boolean }> {
46
48
  let nextCalled = false;
47
49
  const nextResponse = new Response("page content", { status: 200 });
48
- const result = await handler(makeContext(host, authHeader), async () => {
50
+ const result = await handler(makeContext(host, authHeader, pathname), async () => {
49
51
  nextCalled = true;
50
52
  return nextResponse;
51
53
  });
@@ -120,4 +122,21 @@ describe("RFC-0899: access protection middleware", () => {
120
122
  const res = await runMiddleware(handler, "example.com");
121
123
  expect(res.headers.get("X-Robots-Tag")).toBe(null);
122
124
  });
125
+
126
+ it("exempts /manifest.webmanifest from auth on dev.* when PIN is set", async () => {
127
+ mockEnv.ACCESS_PIN = "1234";
128
+ const handler = await loadMiddleware();
129
+ const res = await runMiddleware(handler, "dev.example.com", undefined, "/manifest.webmanifest");
130
+ expect(res._nextCalled).toBe(true);
131
+ expect(res.status).toBe(200);
132
+ expect(res.headers.get("X-Robots-Tag")).toBe("noindex, nofollow, noai, noimageai");
133
+ });
134
+
135
+ it("exempts /manifest.webmanifest from auth on alt.* when PIN is set", async () => {
136
+ mockEnv.ACCESS_PIN = "1234";
137
+ const handler = await loadMiddleware();
138
+ const res = await runMiddleware(handler, "alt.example.com", undefined, "/manifest.webmanifest");
139
+ expect(res._nextCalled).toBe(true);
140
+ expect(res.status).toBe(200);
141
+ });
123
142
  });