Introduce pond-scoped hierarchical labels as the foundation for M4
organization and, later, M5 label-scoped permissions.
- shared: `labels.ts` with the label schemas/views and the pure tree
helpers (buildLabelTree, collectSubtreeIds, collectAncestorIds,
labelDepth, subtreeHeight). These are the single hierarchy walk the
label API and the future permission resolver both build on
(permissions.md: a grant on a label applies to all its descendants).
- prisma: `Label` (self-referential parent_id, unique per (pond, parent,
name), cascade to subtree) and `PageLabel` assignment table; migration.
- api: `LabelsService` + controller. Tree endpoint returns the hierarchy
in one call; create/rename/recolor/move/delete and page assign/unassign.
Validation: cycle prevention on move, depth limit 6, unique name per
(pond, parent) — enforced under a per-pond advisory lock so root-label
uniqueness holds despite Postgres treating NULL parents as distinct.
Delete cascades the subtree and requires `?force=true` when pages are
assigned. Assignment rejects labels from a different pond. Access gated
through InterimAccessService on the owning pond.
- i18n: label error codes and the colour validation message (de + en).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
Users can see who changed what and restore old states (ADR 0013).
- shared: dependency-free word-level Markdown diff (diffMarkdown) with a
unit test; PageVersionContentView; PAGE_RESTORE_CHANNEL + PageRestoreRequest.
- api: GET /pages/:id/versions (list), GET .../:versionId (read-only HTML +
Markdown for diffing), POST .../:versionId/restore. Every route requires
write access — viewing history is gated like editing (permissions.md).
Restore checks permission, then emits the page_restore NOTIFY; history is
append-only (the api never deletes a version).
- collab: a page_restore listener applies the restore on the live document via
openDirectConnection — it snapshots the current state as a PRE_RESTORE
version, then replaces the content in one transaction, so every connected
client converges and the change persists like a normal edit.
- web: HistoryPanel (version list with time/trigger/label/contributors, a
read-only render of a selected version, a Markdown diff against the current
page, and a restore action), toggled from the page menu. de+en strings.
Tests: shared diff (added/removed/round-trip/edges); collab restore DB test
(a connected client converges on the restored content; a pre-restore snapshot
is appended alongside the original — append-only); api list/get/restore
(newest-first, rendered content, write-permission gate, restore returns the
target without mutating history).
This completes M3 (real-time collaboration & history, #33–#42).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
Version history is a core kickoff decision (ADR 0013). Snapshots are full,
self-contained encoded Yjs states, so restore never depends on the update
log and compaction (#40) cannot lose history.
(The page_versions / page_pending_contributors tables and base schema
landed a commit early, bundled into 3583a04; this commit completes #41.)
- schema: page_versions gains created_by (editor of manual/pre-restore
versions; null for automatic snapshots). shared: PageVersionView,
CreateVersionInput, PageVersionTrigger.
- collab: PostgresVersionStore tracks contributors per open doc (onChange),
flushes them to the shared page_pending_contributors accumulator on store,
creates an automatic snapshot on last-participant disconnect (only if
something changed — no duplicate on a quick reconnect) and every 30
active-editing minutes. Contributors and snapshot are consumed atomically.
- api: POST /pages/:id/versions creates a named version (write permission,
label + creator, snapshot reconstructed from persisted state, consumes the
same contributor accumulator). Daily version-thinning scheduler job keeps
all versions for 90 days, then the newest auto snapshot per day; manual and
pre-restore versions are never thinned. pre_restore trigger reserved for #42.
Tests: collab (one auto version on session end with the full two-author
contributor set, none when unchanged, no duplicate on reconnect, interval
snapshot); api (named version stores label+creator, contributor set consumed,
non-owner refused, thinning time-travel keeps newest-per-day beyond window).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
Revoking write access must terminate live sessions and let a user with
pending offline edits export them rather than lose them silently.
Backend (generic, reused by M5 grants #53):
- packages/shared: POND_ACCESS_CHANGED_CHANNEL, the LISTEN/NOTIFY channel
shared by api and collab.
- api: PondAccessNotifier emits pg_notify(pond_access_changed, pondId) on
a permission-relevant change; the single generic seam for revocation.
Wired into pond soft-delete as the interim trigger (see==modify until
#53).
- collab: a dedicated-connection LISTEN listener (LISTEN is connection-
bound, not pooled) that, on a notification, closes every open connection
to the pond's open pages. Clients then reconnect and the api re-issues a
token reflecting current access (downgrade to ro, or 403/404). Reconnects
and re-LISTENs if its connection drops.
Frontend:
- use-collab-provider: a refused token (403/404) on (re)connect sets
accessRevoked and stops the reconnect loop; exposes discardLocal.
- AccessRevokedDialog: keeps local content visible and offers Markdown
copy/download (derived from the live editor doc, so offline edits are
included) and an explicit discard that clears IndexedDB. de+en strings.
Tests: collab DB-backed integration test proves a direct NOTIFY closes a
live session within seconds (AC1) and leaves unrelated ponds untouched;
listener unit tests; api test asserts soft-delete fires the notifier;
web test for the export Markdown derivation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
Editing continues without a connection and merges conflict-free on
reconnect (ADR 0003, realtime-collaboration.md §Offline).
- y-indexeddb mirrors every opened page's Y.Doc to IndexedDB, sharing the
document with the collab provider. The local copy is discarded when the
page is left after a successful server sync (bounding IndexedDB growth)
and kept otherwise so offline edits survive to the next visit.
- vite-plugin-pwa service worker precaches the app shell (build assets only)
with a navigation fallback; `/api` and `/collab` are denylisted and there
is no runtime caching, so API responses are never cached or poisoned.
- Offline page resolution WITHOUT caching API responses: the app itself
persists the small metadata it needs to reopen a visited page (page/pond
ids + slugs, bounded LRU in localStorage) and the last signed-in user, so
after an offline tab reload the app stays signed in, resolves the page, and
restores its content from IndexedDB. Both are revalidated when the network
returns (a 401 clears the cached user).
- Local-only UI: a banner when there are edits held only on this device
(provider `onUnsyncedChanges`), de + en.
Tests: `page-cache` unit test (remember/recall + bounded eviction); a new
`offline` e2e pack (validated locally against the full stack and wired into
CI): edit, reload while offline (shell from the SW, content from IndexedDB),
assert an API call fails offline (no SW API caching), then reconnect and a
second client converges. The e2e static server serves `.webmanifest`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
Seeing other participants live (ADR 0003/0004, realtime-collaboration.md
§Awareness):
- The collaboration-caret extension renders remote carets and selections
with a name flag and a per-user colour. Colours come from a small,
hand-picked palette hashed by user id (FNV-1a), so they are stable across
sessions; a unit test asserts each palette colour clears WCAG AA contrast
(4.5:1) against the white label text.
- A presence strip at the top of the page shows an avatar (initials) per
connected participant, deduplicated by user id, with an overflow count.
Read-only participants appear in the strip (with a marker) but broadcast
no caret — the caret render suppresses read-only users — so the same
awareness feed drives both cursors and presence. Own identity (id +
display name) comes from the auth context into the awareness `user` field.
- Presence updates on every awareness change, so a disconnect drops the
participant within seconds.
The collab e2e pack gains a test: two browsers see each other in the
presence strip, one participant's named caret appears in the other's editor,
and disconnecting removes them. Validated locally against the full stack.
de + en strings and cursor/presence styles added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
The editor now edits over the collaboration server instead of REST — the
moment Dorfteich becomes collaborative (ADR 0003, realtime-collaboration.md).
Web:
- New `useCollabProvider` hook binds a page's Y.Doc to a HocuspocusProvider.
The document loads and persists through the collab server (#35); there is
no REST autosave and no REST seed (a REST seed would fork the doc lineage
and duplicate content). The collab token is fetched lazily on every
(re)connect via an async token function, so an expired token is replaced
transparently and a permission change takes effect on the next reconnect.
- Connection-state UI replaces the save indicator: connecting / connected
("Live") / reconnecting / offline, driven by provider status + navigator
online state. Read-only (`ro`) tokens make the editor non-editable with a
reason; an oversize-document stateless error (#35) surfaces a banner.
- Removed `use-page-autosave.ts` and `yjs-base64.ts` (no longer used).
API:
- `PUT /pages/:id/state` is retired and returns 410 `rest_state_write_retired`
(the criterion deferred here from #35). Collab is the sole writer of page
state; the read paths remain. Removed the now-dead `saveState` service.
e2e / CI:
- The e2e static server proxies the `/collab` WebSocket upgrade (mirrors
Caddy); vite dev gains a `/collab` ws proxy. The auth-e2e CI job starts the
collab server and runs a new collab pack.
- New `collab.spec.ts`: two browsers converge on one page (the milestone
headline), and offline edits continue locally and sync on reconnect. The
read-only live assertion is a `test.fixme` until real read-only grants
exist — under interim access seeing and modifying coincide, so no `ro`
token is issued yet (that arrives with #53). Reworked the api/trash tests
and the content editor-basics test off the retired REST write path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
The api mints a short-lived (60 s) HS256 JWT per page open after an interim
permission check; the collab server authenticates every connection with it
(ADR 0003/0007 — the only JWTs in the system).
- packages/shared: browser-safe token schema/types in `collab-token`, and the
Node `crypto` sign/verify in `token-crypto` behind its own subpath export
(`@dorfteich/shared/token-crypto`) so the web bundle never pulls in
`node:crypto`. Only HS256 is produced/accepted; the signature is checked in
constant time before any untrusted field is read.
- api: `GET /pages/:id/collab-token` (auth-required) returns
{token, mode, expiresInSeconds}; `mode` is rw/ro via the interim access
service; issuance is logged at debug level without the token value.
- collab: `onAuthenticate` verifies the token, checks the pageId matches the
document name, stores {userId, mode} context, and enforces `ro` via
Hocuspocus' read-only connection flag. Hocuspocus' own signal handling is
disabled so index.ts remains the single shutdown owner.
- Shared COLLAB_TOKEN_SECRET env for api + collab (compose, dev overlay,
.env.example, stage docs); a dev default keeps native dev/test/CI running.
Tests: shared token round-trip/rejection; api endpoint e2e (auth required,
claims, 404 for non-members/unknown ids); collab integration via
HocuspocusProvider (valid token connects; expired/tampered/mismatched-page/
wrong-secret rejected; read-only writes dropped, verified with two clients).
Closes#34
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bootstrap apps/collab as a Hocuspocus WebSocket server (ADR 0003):
- pino JSON logging (service=collab) and shared Zod env validation
(collabEnvSchema); structured connection open/close logs.
- /healthz endpoint (process liveness + PostgreSQL ping) served via the
onRequest hook, matching the container-internal path and the proxied
/collab/healthz path; any WebSocket handshake is accepted for now
(authentication arrives with #34, persistence with #35).
- Dockerfile (ESM workspace build) and a compose service on the frontend
and internal networks with a healthcheck; dev overlay service and a new
COLLAB_PORT variable.
- CD builds, pushes, and promotes the collab image; CI builds it on PRs;
the smoke suite asserts /collab/healthz through the reverse proxy.
- deployment.md/stages.md: proxy routing, per-stage COLLAB_PORT, checklist.
Closes#33
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Backend: a generic maintenance-job scheduler (SchedulerService, `jobs`
table) that any later maintenance job registers with instead of
growing its own timer loop. Due-ness and the run-mutex both live in
the DB row (`lastRunAt` survives a restart; claiming a due job is one
atomic `UPDATE ... WHERE status != 'RUNNING'`), and an injectable
ClockService lets tests simulate retention elapsing without waiting or
faking the global clock.
Trash endpoints: GET /ponds/:id/trash (list), POST /pages/:id/restore,
DELETE /pages/:id/purge (manual, bypasses retention) — all sharing the
same purge logic as the scheduled daily job (default 30-day retention,
new trash.retentionDays instance setting). Purging deletes a page's
content cache, update log, and attachment files/quota; page_versions
is a placeholder until M3 exists. Direct navigation to a trashed page
now 404s with a distinguishable `page_trashed` code for editors (a
plain 404 for everyone else) instead of the generic not-found.
Attachment.pageId — added in #27 but never wired up — now gets set on
every page state save to whichever page's document currently embeds
the file, which is what lets purge find a page's files.
Frontend: a per-pond trash view (restore/purge), a "move to trash"
action with confirmation in the page menu, and a trash link in the
sidebar for pond owners. Also fixes react-query retrying 4xx responses
for several seconds by default, which was masking the trash-hint 404
in the UI (and would have affected any other not-found/permission
error the same way).
Closes#31
Wires docToMarkdown/markdownToDoc into the editor clipboard: copying
selected content puts Markdown on text/plain alongside the browser's
own HTML (so pasting into a plain-text destination yields Markdown),
and pasting plain text that looks like a Markdown document converts it
to rich nodes; content with real HTML on the clipboard is left to
ProseMirror's normal HTML-based paste, and the heuristic requires two
or more distinct Markdown-shaped lines (or a fenced code block) so
ordinary prose is never mangled.
Both directions need the parsed/selected doc re-hydrated against
whichever schema instance is on the other side of the boundary: the
canonical editorSchema (packages/shared) for markdownToDoc's output
before inserting it into the live view, and the live view's schema
wrapped back into editorSchema before handing a slice to docToMarkdown
— they're structurally identical but not the same object, and
ProseMirror's content checks are identity-based.
Adds GET /pages/:id/export/markdown (downloads <slug>.md), serving the
already-derived page_content_cache.markdown (#23) rather than
re-decoding the Yjs state. "Copy as Markdown" and "Download as
Markdown" actions in the page header both read from that same
endpoint, so they always agree with each other and with the last saved
state.
Closes#30
A bubble menu on link selection offers "edit URL", "open in new tab",
and "remove link"; Mod-k opens the same editor for the current
selection (creating a link if there isn't one yet), and the toolbar
button does the same. Invalid protocols (e.g. javascript:) show a
localized inline error instead of silently no-oping. Pasting a URL
over selected text links it instead of replacing the text.
Links always render with target="_blank" so read mode opens them in a
new tab by default; edit mode suppresses the resulting navigate-on-
click (Mod-click still follows it), since a plain click there should
place the cursor instead.
Closes#29
Paste and drag-and-drop of image files upload via the #27 API and insert
a real image node only once the upload succeeds; the in-flight state is
a ProseMirror decoration, not a document node, so a failed upload cannot
leave anything broken behind (it shows a transient inline error instead).
The toolbar's image button opens a native file picker into the same
upload path. Selecting an image reveals inline alt-text and width-preset
(small/medium/full) controls. Also fixes the image node's parseDOM,
which had no getAttrs and would drop the required fileId attribute on
internal copy/paste.
Closes#28
Implements the FileStorage abstraction (uploads/<pondId>/<fileId> on the
mounted volume), the attachments model, and POST /ponds/:id/files, GET
/media/:fileId, DELETE /files/:id. Uploads are validated by sniffing
magic bytes rather than trusting the client's Content-Type/filename
(catches a renamed .html-as-.png), checked against the max_file_bytes
and storage_bytes quotas, and served with nosniff + immutable caching.
Closes#27
GET /ponds/:id/pages lists a pond's pages ordered by the pond's
persisted sidebarSort setting (alpha/created; manual arrives with #45).
The sidebar consumes it to show the page list with an active-page
highlight, an owner-only sort switch (persists via the existing
PATCH /ponds/:id), and an inline "new page" flow. The top bar gains a
pond switcher; a new /p/:pondSlug route gives it somewhere to land,
redirecting to the pond's first page once loaded. Sidebar collapse
gains a Ctrl/Cmd+\ shortcut and a slightly refined transition.
Closes#26
TipTap is bound to the canonical ProseMirror schema (packages/shared,
#24) via a generic bridge (spec-utils.ts) that re-derives every
node/mark's attrs/parseDOM/toDOM from editorSchema instead of
duplicating them, so the editor's schema stays byte-for-byte identical
to what the api decodes Yjs states against — guarded by a schema-
fidelity + real Yjs round-trip test (@tiptap/y-tiptap client encoding
against y-prosemirror server decoding).
Route /p/:pondSlug/:pageSlug (RequireAuth) resolves the page via a new
GET /ponds/:pondId/pages/:slug endpoint, binds a local Y.Doc via
@tiptap/extension-collaboration (fragment "default"), and offers a
view/edit mode toggle (sidebar auto-hides in edit mode via a small
AppLayout context). Page state saves debounced to PUT /pages/:id/state
with a truthful saving/saved/error(retrying) indicator; title saves
separately via PATCH /pages/:id.
Toolbar covers headings, marks, lists, blockquote, code block, hr,
table (insert/row/column/header ops via prosemirror-tables), a minimal
link mark, and an image placeholder (real upload is #27/#28).
Closes#25
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Prisma models `pages`/`page_updates`/`page_content_cache` per
data-model.md. Endpoints: POST /ponds/:id/pages (title -> empty Yjs doc
state, seeded via y-prosemirror), GET /pages/:id (meta + base64 state),
PUT /pages/:id/state (client-encoded Yjs state, rejected above the 5 MiB
operations.md limit or if it doesn't decode into a valid document for
the schema), PATCH /pages/:id (title/slug — explicit slug changes
validate uniqueness per pond, title-only renames keep the slug),
DELETE (soft). Access follows InterimAccessService via the page's pond,
same 404-not-403 interim rule as ponds.
State saves decode the Yjs update with yjs + y-prosemirror and run it
through the #24 shared derivation functions (docToPlainText/
docToMarkdown/docToHtml/extractOutline) to refresh page_content_cache.
The Yjs XmlFragment name ("default") and the derivation call are
factored so the collab server's persistence hooks (#35) can reuse both.
Raised the API's JSON body limit to 8 MiB (main.ts and the e2e test
app) to fit base64-encoded page state.
Closes#23
The #24 commit passed ESLint but not the repo's Prettier check (pnpm
lint runs both) — CI caught it after the push. Formatting only, no
behavior change.
ProseMirror schema (headings 1-4, lists incl. task lists, blockquote,
code block, tables via prosemirror-tables, images, hard breaks; bold/
italic/code/strikethrough/link marks) plus docToMarkdown, markdownToDoc,
docToPlainText, docToHtml, and extractOutline built on it. Markdown
parsing extends markdown-it's default preset with a token-stream
transform for GFM task lists and table-cell paragraph wrapping.
docToHtml hand-rolls escaping and link-protocol allowlisting with zero
DOM dependencies, so it runs in the API/collab server as well as the
browser.
Node names `wikilink` and `plugin_block` are reserved for later stories.
Closes#24
- Pond model with pond-level trash columns (ADR 0013) and settings jsonb
holding only deviations from the defaults (sidebar sort, font slots per
ADR 0016); migration 20260705090100_ponds
- shared: pond schemas/views and slugify (German transliteration,
URL-safe, length-capped); deterministic -2/-3 suffixes for collisions
- InterimAccessService: single place answering pond access questions
until the real role model lands in M5
- POST/GET /ponds, GET /ponds/:slug, PATCH/DELETE /ponds/:id, Site-Admin
trash + restore; personal pond auto-created on e-mail verification and
for active seed fixtures; personal ponds cannot be trashed
- e2e pack covering verify-flow pond creation, slug suffixes, rename,
foreign-pond 404s, trash/restore; slugify unit tests
Closes#21
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpQz6ypHJsLfMf4S6fyQEB
Composing z.object() in the web app around a schema imported from
@dorfteich/shared mixes two zod type instances and breaks the
zodResolver overload on fresh installs (CI). Like the other forms,
the schema now lives in the shared package.
Part of #20
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The web app grows its account surface: login (with next-redirect,
unverified-hint + resend), signup (react-hook-form + shared Zod
schemas, field-level api errors, closed-registration state fed by the
new public GET /auth/registration), e-mail verification, forgot/reset
password; a settings page with profile (locale applies immediately),
password change, and active-session management; a Site-Admin page for
instance name, default locale, and registration mode. AuthProvider
holds /auth/me, applies the profile locale, and backs route guards
(RequireAuth/RequireAnonymous/RequireSiteAdmin); the top bar gains a
user menu. All strings ship in the new auth/settings namespaces (de+
en); the exception filter now preserves handler-specific error codes.
Verified live: signup → Mailpit → verify → login → profile through
the Vite proxy.
Closes#16Closes#17Closes#18Closes#19
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MailService renders transactional mails (verify-email, reset-password)
from the new de/en `mails` i18n namespace — text plus minimal HTML
with escaped interpolation — and enqueues them into mail_outbox.
MailWorker delivers pending rows every 15s through an injectable
transport (nodemailer; faked in tests) with quadratic backoff and a
permanent FAILED state after five attempts, logged as a warning.
SMTP_* and APP_BASE_URL join the environment schema with defaults
matching the new Mailpit container in the dev overlay.
Closes#12
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prisma models per data-model.md: users (status enum, site-admin flag),
user_identities (password provider now, OIDC later — subject is the
stable user id), sessions (hashed ids), auth_tokens (hashed, single-
use), plus rate_limits and mail_outbox for the upcoming M1 stories.
UsersService creates accounts transactionally with Argon2id-hashed
password identities (OWASP parameters, rehash detection) and maps
uniqueness violations to field-level conflicts. Database-backed suites
run when TEST_DATABASE_URL is set — locally against the dev db, in CI
via a new postgres service container; shared auth schemas (username,
password policy incl. common-password blocklist) ship with tests.
Closes#10
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Translation resources live in packages/shared/i18n/<lang>/<ns>.json
(common, errors) and ship with de and en. The web app initializes
react-i18next with bundled resources (?lng= wins, then the browser
language); all shell components use useTranslation and the temporary
t() stub is gone. The api localizes its uniform error bodies via a
minimal i18next instance negotiated from Accept-Language. `pnpm
i18n:check` fails CI when any key is missing in any language, backed
by tested helpers in @dorfteich/shared.
Closes#5
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
apps/api gains Prisma (instance_settings as the first model) with the
initial migration applied automatically at startup via prisma migrate
deploy, a lazy-connecting PrismaService, and GET /api/v1/readyz
reporting named checks (database reachable, migrations applied) with
200/503. DATABASE_URL joins the validated environment schema;
MIGRATE_ON_START=false skips deploys for tests and tooling. An
idempotent seed script and a Compose dev overlay with PostgreSQL
(host port 5434 — 5433 is taken locally) complete the loop.
Closes#3
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
apps/api boots a NestJS application with: Zod-validated environment
configuration (schema in @dorfteich/shared, fails fast listing every
invalid variable), structured pino request logging via nestjs-pino
(pretty in development, JSON otherwise, auth headers redacted), a
global exception filter producing the uniform ApiErrorBody shape, and
GET /api/v1/healthz. Vitest runs Nest through SWC for decorator
metadata; supertest covers healthz and the 404 error shape.
Closes#2
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pnpm workspace with apps/web, apps/api, apps/collab, and
packages/shared; strict TypeScript base config, repo-wide ESLint (flat)
+ Prettier, Vitest per package, and root scripts lint/typecheck/test/
build. @dorfteich/shared ships a first health-response helper consumed
by apps/api to prove workspace linking. Existing markdown docs are
reformatted once by the new Prettier setup.
Closes#1
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>