create-ailk 0.1.1 → 0.2.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 (40) hide show
  1. package/component-catalog.md +175 -14
  2. package/dist/cli.js +0 -0
  3. package/package.json +14 -15
  4. package/templates/apps/api/CLAUDE.md +4 -1
  5. package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
  6. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +11 -0
  7. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +127 -0
  8. package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +221 -0
  9. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +468 -0
  10. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +707 -0
  11. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +11 -0
  12. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +467 -0
  13. package/templates/apps/api/src/routes/project-listings/index.ts +15 -0
  14. package/templates/apps/api/src/routes/project-listings/me.ts +76 -0
  15. package/templates/apps/api/src/routes/project-listings/start.ts +8 -0
  16. package/templates/apps/api/src/server.ts +14 -0
  17. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
  18. package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
  19. package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +684 -0
  20. package/templates/apps/api/src/services/consent.ts +236 -0
  21. package/templates/apps/api/src/services/flow-engine.ts +18 -0
  22. package/templates/apps/api/src/services/listing-promotion.ts +342 -0
  23. package/templates/apps/api/src/services/project-listing-decision.ts +21 -1
  24. package/templates/apps/api/src/services/project-listings.ts +373 -25
  25. package/templates/apps/web/app/[locale]/layout.tsx +13 -1
  26. package/templates/apps/web/jest.config.cjs +6 -0
  27. package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
  28. package/templates/apps/web/lib/site-brand.tsx +4 -1
  29. package/templates/apps/web/lib/site-theme.ts +74 -0
  30. package/templates/apps/web/package.json +1 -0
  31. package/templates/content/_site.mdx +12 -0
  32. package/templates/database/CHANGELOG.md +61 -0
  33. package/templates/database/inbox/schema.prisma +98 -0
  34. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
  35. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
  36. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
  37. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
  38. package/templates/database/package.json +1 -1
  39. package/templates/database/scripts/db-generate-locked.sh +0 -0
  40. package/templates/package.json +1 -1
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Round trip: the real `content/_site.mdx` → the emitted CSS custom properties.
3
+ *
4
+ * This is the end-to-end claim the wiring makes — a site declares a theme and
5
+ * the built page renders in those colours — asserted on the actual repo file
6
+ * rather than a fixture, so deleting the `theme:` block would fail here.
7
+ */
8
+ import { mkdtempSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ import { PartialColorPaletteSchema } from "@working-theory/site-config/schema";
13
+ import { siteColorTokenMap, themeToCssVarsBothModes } from "@working-theory/theme";
14
+
15
+ import { loadSiteBrandTokens } from "../site-theme";
16
+
17
+ const REPO_CONTENT_ROOT = join(__dirname, "..", "..", "..", "..", "content");
18
+
19
+ /** The light-mode `:root { … }` block, without the dark one. */
20
+ function lightBlock(css: string): string {
21
+ return css.slice(0, css.indexOf(':root[data-theme="dark"]'));
22
+ }
23
+
24
+ /** The dark-mode `:root[data-theme="dark"] { … }` block. */
25
+ function darkBlock(css: string): string {
26
+ return css.slice(css.indexOf(':root[data-theme="dark"]'));
27
+ }
28
+
29
+ describe("loadSiteBrandTokens — the real content/_site.mdx", () => {
30
+ const originalRoot = process.env.AILK_CONTENT_ROOT;
31
+
32
+ beforeEach(() => {
33
+ process.env.AILK_CONTENT_ROOT = REPO_CONTENT_ROOT;
34
+ });
35
+
36
+ afterEach(() => {
37
+ if (originalRoot === undefined) delete process.env.AILK_CONTENT_ROOT;
38
+ else process.env.AILK_CONTENT_ROOT = originalRoot;
39
+ });
40
+
41
+ it("translates the declared theme into the token overlay", () => {
42
+ expect(loadSiteBrandTokens()).toEqual({
43
+ colors: {
44
+ light: { bg: "#fdfcfa", fg: "#1c1917" },
45
+ dark: { bg: "#0c0a09" },
46
+ },
47
+ });
48
+ });
49
+
50
+ it("reaches the emitted CSS custom properties", () => {
51
+ const brand = loadSiteBrandTokens();
52
+ const css = themeToCssVarsBothModes(brand ? { brand } : {});
53
+
54
+ expect(lightBlock(css)).toContain("--color-bg: #fdfcfa;");
55
+ expect(lightBlock(css)).toContain("--color-fg: #1c1917;");
56
+ expect(darkBlock(css)).toContain("--color-bg: #0c0a09;");
57
+ });
58
+
59
+ it("changes exactly the declared variables and nothing else", () => {
60
+ const brand = loadSiteBrandTokens();
61
+ const themed = themeToCssVarsBothModes(brand ? { brand } : {});
62
+ const expected = themeToCssVarsBothModes()
63
+ // light block — the two declared light colours
64
+ .replace("--color-bg: #ffffff;", "--color-bg: #fdfcfa;")
65
+ .replace("--color-fg: #030712;", "--color-fg: #1c1917;")
66
+ // dark block — the one declared dark colour
67
+ .replace("--color-bg: #0a0a0a;", "--color-bg: #0c0a09;");
68
+
69
+ expect(themed).toBe(expected);
70
+ });
71
+
72
+ it("treats a bare `theme:` key as no theme, rather than throwing", () => {
73
+ // YAML parses a dangling `theme:` to null. It is what commenting out the
74
+ // block's children leaves behind, it means the same as an absent key, and
75
+ // it must not escape as a ZodError out of an async Server Component.
76
+ const root = mkdtempSync(join(tmpdir(), "ailk-site-theme-"));
77
+ writeFileSync(
78
+ join(root, "_site.mdx"),
79
+ ["---", "siteName: Test", "theme:", "---", ""].join("\n"),
80
+ "utf8",
81
+ );
82
+ process.env.AILK_CONTENT_ROOT = root;
83
+
84
+ expect(loadSiteBrandTokens()).toBeUndefined();
85
+ });
86
+
87
+ it("returns undefined — and so emits the default CSS — when no config is readable", () => {
88
+ process.env.AILK_CONTENT_ROOT = join(__dirname, "no-such-content-root");
89
+
90
+ const brand = loadSiteBrandTokens();
91
+ expect(brand).toBeUndefined();
92
+ expect(themeToCssVarsBothModes(brand ? { brand } : {})).toBe(
93
+ themeToCssVarsBothModes(),
94
+ );
95
+ });
96
+ });
97
+
98
+ /**
99
+ * The twelve customer-facing colour slots live in `@working-theory/site-config`
100
+ * and the map from them to token names lives in `@working-theory/theme`;
101
+ * neither package may import the other, so nothing inside either can notice
102
+ * the two lists drifting. `apps/web` imports both, so the cross-check lives
103
+ * here — without it, a slot added to the palette schema would parse cleanly
104
+ * and then throw "is not a theme colour" at render.
105
+ */
106
+ describe("the two vocabularies agree on which slots exist", () => {
107
+ it("siteColorTokenMap covers exactly the schema's colour slots", () => {
108
+ expect(Object.keys(siteColorTokenMap).sort()).toEqual(
109
+ Object.keys(PartialColorPaletteSchema.shape).sort(),
110
+ );
111
+ });
112
+ });
@@ -55,8 +55,11 @@ interface SiteFrontmatter {
55
55
  * levels up from cwd — `apps/web` in dev/build, so `../../content` lands on
56
56
  * the site/scaffold root's `content/` directory. Duplicated locally because
57
57
  * that resolver isn't part of `@working-theory/page-renderer`'s public API.
58
+ *
59
+ * Exported so `lib/site-theme.ts` resolves the same root rather than keeping
60
+ * a third copy of this four-line rule.
58
61
  */
59
- function resolveContentRoot(): string {
62
+ export function resolveContentRoot(): string {
60
63
  const override = process.env.AILK_CONTENT_ROOT;
61
64
  if (override && override.length > 0) return override;
62
65
  return join(process.cwd(), "..", "..", "content");
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Site theme — reads the `theme:` block out of `content/_site.mdx` and
3
+ * translates it into the token overlay `themeToCssVarsBothModes` accepts.
4
+ *
5
+ * The read is a local frontmatter parse rather than
6
+ * `@working-theory/site-config/server`'s `loadSiteConfig`, for the same
7
+ * reason `site-brand.tsx` reads locally (see its header, and #4157 D1) and
8
+ * one more: `architecture.yaml` forbids `apps/web` from importing that
9
+ * subpath at all, because it pulls `node:fs` into a tree that also carries
10
+ * edge routes (#1567). The `theme:` block is still validated against the
11
+ * canonical schema — `SiteThemeSchema` from the package's client-safe
12
+ * `/schema` entry — so the shape this app accepts and the shape the
13
+ * dashboard will write are one definition, not two.
14
+ *
15
+ * The translation itself lives in `@working-theory/theme`
16
+ * (`siteThemeToBrandTokens`) — the token vocabulary is that package's own.
17
+ *
18
+ * Returns `undefined` when the site declares no theme, so the layout passes
19
+ * no `brand` at all and the resolver emits byte-for-byte the CSS it emitted
20
+ * before this wiring existed.
21
+ */
22
+
23
+ import { readFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+
26
+ import { SiteThemeSchema } from "@working-theory/site-config/schema";
27
+ import {
28
+ siteThemeToBrandTokens,
29
+ type BrandTokenOverrides,
30
+ } from "@working-theory/theme";
31
+ import { parse as parseYaml } from "yaml";
32
+
33
+ import { resolveContentRoot } from "./site-brand";
34
+
35
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
36
+
37
+ const SOURCE = "content/_site.mdx theme";
38
+
39
+ /**
40
+ * The `theme:` value as it appears in the frontmatter, or `undefined` when
41
+ * `_site.mdx` is absent, unreadable, or declares no theme. A site with no
42
+ * readable config has no theme to apply, and the default palette is the
43
+ * correct result — the same fallback `site-brand.tsx` takes.
44
+ *
45
+ * "Declares no theme" covers a bare `theme:` key with nothing under it, which
46
+ * YAML parses to `null`. That is the shape the most natural edit produces —
47
+ * commenting out the block's children leaves the key dangling — and it means
48
+ * exactly what an absent key means, so it takes the same path rather than
49
+ * reaching the schema and throwing out of a Server Component.
50
+ */
51
+ function readThemeBlock(): unknown {
52
+ try {
53
+ const raw = readFileSync(join(resolveContentRoot(), "_site.mdx"), "utf8");
54
+ const match = FRONTMATTER_RE.exec(raw);
55
+ if (!match) return undefined;
56
+
57
+ const parsed: unknown = parseYaml(match[1] ?? "");
58
+ if (!parsed || typeof parsed !== "object") return undefined;
59
+
60
+ return (parsed as Record<string, unknown>).theme ?? undefined;
61
+ } catch {
62
+ return undefined;
63
+ }
64
+ }
65
+
66
+ export function loadSiteBrandTokens(): BrandTokenOverrides | undefined {
67
+ const block = readThemeBlock();
68
+ if (block === undefined) return undefined;
69
+
70
+ // Validation failures and unsupported colours BOTH throw, deliberately: a
71
+ // customer who set a colour and saw no change would have been lied to.
72
+ const theme = SiteThemeSchema.parse(block);
73
+ return siteThemeToBrandTokens(theme, SOURCE);
74
+ }
@@ -31,6 +31,7 @@
31
31
  "@working-theory/schema": "workspace:*",
32
32
  "@working-theory/schema-pack-registry": "workspace:*",
33
33
  "@working-theory/schema-validator": "workspace:*",
34
+ "@working-theory/site-config": "workspace:*",
34
35
  "@working-theory/templates": "workspace:*",
35
36
  "@working-theory/theme": "workspace:*",
36
37
  "@working-theory/ui": "workspace:*",
@@ -14,6 +14,13 @@ siteIdentity:
14
14
  sameAs: []
15
15
  analytics:
16
16
  vercelAnalytics: false
17
+ theme:
18
+ colors:
19
+ light:
20
+ bg: "#fdfcfa"
21
+ text: "#1c1917"
22
+ dark:
23
+ bg: "#0c0a09"
17
24
  ---
18
25
 
19
26
  <!--
@@ -31,6 +38,11 @@ your LinkedIn company page, your GitHub org, your X profile. Answer engines use
31
38
  it to reconcile your site with the rest of your presence, so it is worth
32
39
  filling in.
33
40
 
41
+ `theme.colors` is the site's colour overlay: each key names a customer-facing
42
+ colour role (`bg`, `text`, `primary`, `accent`, …) per light/dark mode, and the
43
+ build turns it into the CSS custom properties every component reads. Declare
44
+ only what you change — an absent role keeps the platform default.
45
+
34
46
  For the agent-operator workflow, use the `set_site_identity` MCP tool rather
35
47
  than editing this file directly — it validates input and writes with optimistic
36
48
  locking.
@@ -1,5 +1,66 @@
1
1
  # @working-theory/database
2
2
 
3
+ ## 0.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#5282](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/5282) [`49e6ba4`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/49e6ba470a0ff4a0e6d4f7d14147232e90a12259) Thanks [@claudio-planck](https://github.com/claudio-planck)! - Consent purposes and grants ([#5275](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/5275)): a site declares its consent purposes once, any form renders them as real checkboxes with inline policy links, and every answer produces a durable record carrying the exact wording the person read.
8
+
9
+ `@working-theory/validation` gains `consent-config.ts` — the per-site consent registry contract (`siteConsentConfigSchema`, `SITE_CONSENT_CONFIG_DEFAULTS`, `consentConfigFor`, `resolveConsentStatement`), following `SiteListingConfig`: a Zod-validated blob, platform defaults, and an unparseable blob that resolves to those defaults rather than to a failed form. A statement's links are markdown-shaped with a KEY target (`[Privacy Policy](privacy)`) resolved against the site's policy map, so a config string can never carry a URL or a scheme. `fieldInputTypeSchema` gains the `consent` kind, carrying `purposes: string[]` (ids only; `options`/`minLength` are refused on it). `projectListingStartSchema` keeps `consent: z.literal(true)` and gains an optional `consents: Record<purposeId, boolean>` and `locale`, so every existing caller (agent submission included) still parses. The listing application's contact step compiles its consent control as the `consent` kind (an optional `consentPurposes` list, defaulting to the platform's `service_terms`) instead of a `select` carrying one fake option.
10
+
11
+ `@working-theory/ui` gains two blocks: `ConsentStatement` (parses `[Label](key)` tokens; an unresolvable key renders as plain text, never a dead link; no code path renders an href taken from the statement string) and `ConsentGroup` (one `Checkbox` per purpose, every box unticked on first render, each named and described by its statement via `aria-labelledby`/`aria-describedby`, posting under a flat `<field>.<purposeId>` name). `ContactFormFields` accepts `type: "consent"`; `FlowStepper` gates advance on required purposes only and sends one `"true"`/`"false"` per purpose (a decline is a value, not an absent key); both take an optional `consentConfig` that defaults to the platform registry. `ListingApplicationFlow` now sends the founder's real grants (`consents` + `locale`) instead of a hardcoded `consent: true`.
12
+
13
+ `@working-theory/database` gains the append-only `ConsentGrant` model (`consent_grants`): subject as a type + id pair of plain scalars (no cross-namespace relation), declines stored as `granted: false`, the resolved statement denormalized with its hash and served locale, no `updatedAt` and no unique constraint on subject + purpose. The migration backfills one row per pre-existing listing marked wording-unrecoverable, stamped at the listing's own `consent_at`, and never touches `project_listings`.
14
+
15
+ Additive: a flow, form, or site declaring no consent config renders and parses exactly as before. The one deliberate behavior change is the listing application's consent control.
16
+
17
+ - [#5294](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/5294) [`cf6b325`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/cf6b325298e92d22ab7edfe8538f5ca17f54ebfd) Thanks [@claudio-planck](https://github.com/claudio-planck)! - The founder application becomes data ([#5285](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/5285)): a published field contract, questions and screens as records, and override by exception.
18
+
19
+ `@working-theory/validation` gains `listing-application-contract.ts` — the fields the platform actually READS, each with the call site that reads it, which of them a published listing cannot do without, and the control a question writing one must declare. Two beliefs the design carried did not survive that grounding and are recorded in the module header: there is no `FAQPage` emission for a listing in this tree, and no resume-link email. `listing-application.ts` gains the question record (an id, a control, copy, options, validation, and either a `writesTo` naming a contract field or a `siteAnswer` destination carrying a visibility flag), the screen record (its own copy, an ordered list of question ids, and its own side-panel declaration), merge-by-id so a site rewording one question inherits every other default including ones added later, and a parse-time invariant: exactly one question writes each required contract field, with zero and two both named failures. The compiler emits ONE step per screen carrying every field its questions contribute — the generalization of the `contact` and `faqs` cases, not a third mechanism. `listing-application-defaults.ts` ships a complete default question set and a grouped arrangement following combine-selection, separate-composition; it deliberately reverses the ships-no-copy discipline and says why.
20
+
21
+ `@working-theory/ui` reaches two capabilities that already existed and were thrown away in one line. `ListingApplicationFlow` returned the live tile unconditionally on every step, ignoring the view `FlowStepper` handed it; a screen declares its own side panel now, and a screen declaring none renders a full-width question with `FlowFrame`'s side region collapsed. `FlowFrame` gains a side cell with the same padding, measure and centring the active cell's step card has, so side content no longer hand-rolls its own inset, and its side region gains `md:flex-1` — without it the region carried `md:w-1/2` against an active region carrying `flex-1`, so a caller's gap came out of the question column alone. `FlowStepper` measures the step card and sizes the side cell to the tallest card observed, never shrinking, capped at the VISIBLE slot height rather than the frame's declared `h-dvh` — a page mounting the frame below a header makes it taller than the viewport.
22
+
23
+ `@working-theory/database` gains the bounded site-answers store on `ProjectListing`, and the logo columns become writable from the application.
24
+
25
+ Additive, with two boundaries stated precisely. The flat `steps` config parses and compiles to the byte-identical `FlowConfig` — verified old-against-new on a fifteen-step config exercising every step kind — and `steps` is now optional, with a config declaring both shapes, or declaring `steps` as an explicit `undefined`, a named parse failure. What is NOT unchanged is the rendered frame: the side-cell container, the shared gap and the measured panel height are fixes to `FlowFrame` and `FlowStepper`, so they reach every consumer of those two components by design, not only the listing application.
26
+
27
+ A site that overrides one question's copy and declares no `screens` inherits every other default and renders new defaults added later. A site that declares its own `screens` inherits new defaults into its question set but does not render them — declared screens are that site's statement of what it asks, and inserting a screen into a live form is not something an upstream release should do. A new default that writes a REQUIRED contract field still fails that site's config at parse, by name.
28
+
29
+ The three fields the application gained — the two logo URLs and the requested window — are OPT-IN: they are excluded from the default collected-field mask, so adding them cannot widen what an anonymous caller may write on a site that never asked for them. A site that wants them names them.
30
+
31
+ - [#5298](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/5298) [`57b0b04`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/57b0b047eb3f04d40f0712a893c89415b742c7f5) Thanks [@claudio-planck](https://github.com/claudio-planck)! - A verified owner link on a listing ([#5295](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/5295)): a founder who signs in can read their own submitted listings, and nobody else's.
32
+
33
+ `@working-theory/database` gains `ProjectListing.ownerUserId` / `ownerLinkedAt` — plain nullable scalars with no cross-namespace relation, exactly the shape `Lead.promotedUserId` / `promotedAt` already has (ADR 0009 invariant [#1](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/1)). A listing carried `founderEmail` and `leadId` and no user link, so the only join available for "the listings belonging to this signed-in person" was a session's email against `founderEmail`. That string is typed into an anonymous form and proves nothing: two people who type the same address are two people, and the join hands one of them the other's submission. These columns are the ownership claim a read may actually join on.
34
+
35
+ The link is stamped only when all three of these hold: the reconciling user's own email is VERIFIED; that verified address matches the row's `founderEmail`; and the row's site resolves to one of that user's own tenant workspaces. Any one failing writes nothing and returns a count rather than throwing — the reconciliation path would otherwise fail an unrelated request. The email comparison is exact equality against the same normalization the write path applies, deliberately not Prisma's `mode: "insensitive"`, which compiles to `ILIKE` and would make `_` and `%` in the caller's own address wildcards that capture a neighbour's row.
36
+
37
+ A fourth condition has no counterpart in the lead service: the match must be UNAMBIGUOUS. Two strangers can type the same address into the anonymous form, and when they do nothing says which of the two un-linked rows is whose — so a row is claimed only when it is the only un-linked row carrying that address at its site, and an ambiguous group stays null. The cost is a founder who legitimately submitted two applications at one site, which the concurrent limit allows: neither is linked. That is the deliberate trade, because nothing in the data separates their second application from a stranger's first. The write also repeats `ownerUserId: null`, making it a compare-and-swap, so two concurrent reconciliation runs cannot transfer a row away from whoever claimed it first.
38
+
39
+ Candidacy is also restricted to submitted rows — every stored status except `draft`. A draft is addressed by its resume token, which is cleared at submit, so the token and the owner link hand off to each other cleanly and a draft needs no link. It is also a security boundary: draft creation is anonymous with the quota enforced at submit, so a claimable draft would let one unauthenticated request either disclose a stranger's text or, under the ambiguity refusal, permanently block someone from claiming their own listing.
40
+
41
+ `@working-theory/validation` gains `projectListingMeResponseSchema` — an array of the existing founder-facing `projectListingDraftSchema`, reused rather than forked because the audience is the same person that shape already serves. Nothing existing changes.
42
+
43
+ No existing row is backfilled, now or later. Every row predates verification, so deriving a link from `founderEmail` would assert the thing nobody proved. A listing submitted by someone who never signs in stays null for the life of the row, which is a legitimate resting state rather than a backlog.
44
+
45
+ Additive throughout. Both columns are nullable with no default; the new schema export is new; the operator's `owner|admin` floor on the existing tenant-scoped listing routes is unchanged, and the founder reads their own rows through the owner link rather than through a relaxed floor.
46
+
47
+ - [#5283](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/5283) [`a713ea7`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/a713ea78c12c1dc2519b80831deed197a4f227b4) Thanks [@claudio-planck](https://github.com/claudio-planck)! - A structured `address` field type in the flow vocabulary, and structured address storage for listings ([#5279](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/5279)).
48
+
49
+ `AddressFieldset` has shipped since [#4395](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/4395) and `StepFlow` has rendered it ever since, but `fieldInputTypeSchema` exposed six input types and `address` was not one of them — so a flow config could not name the composite, and the founder application collected a whole published business address in a single free-text box capped at 300 characters.
50
+
51
+ `fieldInputTypeSchema` gains `"address"`. The two cross-field rules already beneath that enum cover the new member without a new clause: `options` is select-only and `minLength` is text/textarea-only, so an address field declaring either is a named parse failure rather than a silently discarded setting. `flowAddressValueSchema` and `composeAddressLine` are new exports — the first declares the six-part value an address field submits, the second composes the single-line display string from those parts. There is deliberately no function that runs the conversion the other way.
52
+
53
+ `FlowStepper` reads, validates, gates, seeds and submits the composite against the six fixed keys `AddressFieldset` posts under, which is the handling `StepFlow` has carried since [#4395](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/4395). Required means every required sub-control has a value rather than that a key named `address` is non-empty. `ContactFormFields` already rendered the type and is unchanged, as are `AddressFieldset` and `ADDRESS_COUNTRIES`.
54
+
55
+ `ProjectListing` keeps `address` as the single-line display string and gains six nullable columns for the parts beside it. A new application composes its display string from the parts, server-side. An existing row keeps its display string exactly as the founder typed it and its parts stay null — no stored address is ever parsed, split, or rewritten, and the migration does not backfill.
56
+
57
+ Every change is additive: a flow config that declares no address field parses, renders and submits exactly as it did before, and a listing carrying only the legacy free-text address reads unchanged.
58
+
59
+ ### Patch Changes
60
+
61
+ - Updated dependencies [[`49e6ba4`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/49e6ba470a0ff4a0e6d4f7d14147232e90a12259), [`cf6b325`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/cf6b325298e92d22ab7edfe8538f5ca17f54ebfd), [`767a35c`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/767a35ca83155f774667ea3f7a10eb0ce1c094ab), [`57b0b04`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/57b0b047eb3f04d40f0712a893c89415b742c7f5), [`65989b4`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/65989b4dc6915bf37608475ed6409f98cd5945e2), [`a713ea7`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/a713ea78c12c1dc2519b80831deed197a4f227b4)]:
62
+ - @working-theory/validation@0.20.0
63
+
3
64
  ## 0.19.0
4
65
 
5
66
  ### Minor Changes
@@ -460,8 +460,47 @@ model ProjectListing {
460
460
  // #5036 D6 — a legal control, not a contact detail: visitors answering this
461
461
  // founder's diagnostic are owed the identity and address of the party
462
462
  // collecting their data. Carried on the PUBLIC read for that reason.
463
+ //
464
+ // #5279 — still the single-line DISPLAY string. Where the six part columns
465
+ // below are written, this is COMPOSED from them server-side; where they are
466
+ // not, it is the line the founder typed, verbatim.
463
467
  address String?
468
+ // #5279 — the structured parts of the address above, beside it rather than
469
+ // instead of it. Nullable for the same reason as the #5036 D1 block above: a
470
+ // row written before #5279 has no parts, and none may ever be GUESSED from
471
+ // its `address` string. Parts compose into a line deterministically; a line
472
+ // does not decompose into parts, and a wrong split silently corrupts the
473
+ // party identification the listing's privacy notice points at — so the
474
+ // migration deliberately backfills nothing and a null part stays null for
475
+ // the life of the row. Composition runs one way only, in `draftUpdateData`
476
+ // (apps/api/src/services/project-listings.ts).
477
+ addressLine1 String? @map("address_line1")
478
+ addressLine2 String? @map("address_line2")
479
+ addressCity String? @map("address_city")
480
+ addressRegion String? @map("address_region")
481
+ addressPostalCode String? @map("address_postal_code")
482
+ addressCountry String? @map("address_country")
464
483
  questions Json?
484
+ // #5285 — the answers to the site's OWN questions: one flat JSON object of
485
+ // `{ questionId: answer }` for everything a site asks that the PLATFORM does
486
+ // not read. The field contract (packages/validation/.../listing-
487
+ // application-contract.ts) names what the platform reads and therefore what
488
+ // earns a column; everything else was previously unaskable without a
489
+ // migration and a release, which is what this column removes.
490
+ //
491
+ // BOUNDED, not free-form, and that is the point: the write path is
492
+ // anonymous (a resume token and nothing else), so an unbounded JSON bag on
493
+ // it is a place for a stranger to park arbitrary text in the table. The
494
+ // server accepts only ids the SITE declared in its own `SiteListingConfig`,
495
+ // at that site's own count and length caps, and refuses the whole request
496
+ // otherwise — so what lands here is never larger than the questions the
497
+ // operator chose to ask. Null on every row written before #5285 and on
498
+ // every site that asks nothing of its own.
499
+ //
500
+ // Only the `public`-visibility subset rides the public read; an
501
+ // `operator-only` answer reaches the dashboard and the review payload and
502
+ // stops there.
503
+ siteAnswers Json? @map("site_answers")
465
504
  founderName String @map("founder_name")
466
505
  founderEmail String @map("founder_email")
467
506
  founderCompany String? @map("founder_company")
@@ -512,6 +551,24 @@ model ProjectListing {
512
551
  // rolls back on a Slack failure).
513
552
  slackChannel String? @map("slack_channel")
514
553
  slackTs String? @map("slack_ts")
554
+ // Listing -> owner-user link (#5295). Plain scalars — no cross-namespace
555
+ // @relation into auth.User (ADR 0009 invariant #1), exactly as
556
+ // `Lead.promotedUserId` / `promotedAt` above. Set by
557
+ // apps/api/src/services/listing-promotion.ts once the reconciling user's own
558
+ // email is VERIFIED, that verified address matches `founderEmail`, and this
559
+ // row's `siteId` resolves to one of that user's own workspaces.
560
+ //
561
+ // `founderEmail` is an UNVERIFIED string typed into an anonymous form, so it
562
+ // can never by itself say who owns the row: two people who type the same
563
+ // address are two people. This column is the only ownership claim any read
564
+ // may join on, and nothing derives it from `founderEmail` at read time.
565
+ //
566
+ // Nullable PERMANENTLY, not pending a backfill. A listing submitted by
567
+ // someone who never signs in has no owner for the life of the row, and that
568
+ // is a legitimate resting state: a backfill from `founderEmail` would assert
569
+ // a link nobody proved, which is the bug this column exists to remove.
570
+ ownerUserId String? @map("owner_user_id")
571
+ ownerLinkedAt DateTime? @map("owner_linked_at")
515
572
  createdAt DateTime @default(now()) @map("created_at")
516
573
  updatedAt DateTime @updatedAt @map("updated_at")
517
574
 
@@ -521,5 +578,46 @@ model ProjectListing {
521
578
  // rows on every submit, from an anonymous route. Indexed so a stranger
522
579
  // cannot make that count a table scan.
523
580
  @@index([siteId, founderEmail])
581
+ // The self-scoped read's whole WHERE clause (#5295) is `{ ownerUserId }` —
582
+ // the owner link IS the scope, so no site term joins it. Single-column by
583
+ // design: the promotion match (`founderEmail` + `siteId` + null owner) is
584
+ // already served by the (site_id, founder_email) index above.
585
+ @@index([ownerUserId])
524
586
  @@map("project_listings")
525
587
  }
588
+
589
+ /// ConsentGrant — one person's answer to one consent purpose, with the exact
590
+ /// wording they read (#5275). APPEND-ONLY: current state is the newest row
591
+ /// per (subject, purpose), so a later withdrawal never destroys the evidence
592
+ /// of the earlier grant (D9) — no `updatedAt`, and deliberately NO unique
593
+ /// constraint on subject + purpose. DECLINES ARE STORED as `granted: false`
594
+ /// (D8): an absent row cannot distinguish "they said no" from "we never
595
+ /// asked", and on an optional purpose that distinction is the only thing the
596
+ /// record is for. The subject is a type + id pair of plain scalars
597
+ /// (`project_listing` / `subscriber` / …) with NO cross-namespace @relation
598
+ /// (D10, ADR 0009 invariant 1) — which is what lets a new surface adopt
599
+ /// consent with a service call and no migration. `statement` is the resolved
600
+ /// text DENORMALIZED at write time (D7): a pointer into the site's config row
601
+ /// would prove nothing once that row is edited, and `statementHash` groups
602
+ /// identical wordings. `locale` is the locale the statement was actually
603
+ /// served in, not the one requested. `purposeId` is the site-config registry
604
+ /// id — the join key across surfaces. Written only by
605
+ /// apps/api/src/services/consent.ts.
606
+ model ConsentGrant {
607
+ id String @id @default(dbgenerated("('cnsnt_'::text || (uuidv7())::text)")) @db.Text
608
+ siteId String @map("site_id")
609
+ subjectType String @map("subject_type")
610
+ subjectId String @map("subject_id")
611
+ purposeId String @map("purpose_id")
612
+ granted Boolean
613
+ locale String
614
+ statement String @db.Text
615
+ statementHash String @map("statement_hash")
616
+ createdAt DateTime @default(now()) @map("created_at")
617
+
618
+ // "What did this subject agree to" — every row for one subject, newest last.
619
+ @@index([siteId, subjectType, subjectId])
620
+ // "Who agreed to this purpose" — the cross-surface count the id exists for (D1/D2).
621
+ @@index([siteId, purposeId])
622
+ @@map("consent_grants")
623
+ }
@@ -0,0 +1,32 @@
1
+ -- The founder's address, in structured parts, on `project_listings` (#5279).
2
+ --
3
+ -- The application collected a whole business address in ONE free-text box
4
+ -- (`address`, a single line). That line is a legal control rather than a
5
+ -- contact detail — visitors answering a founder's diagnostic are owed the
6
+ -- identity and address of the party collecting their data (#5036 D6) — and a
7
+ -- notice that has to name a country or a region cannot reliably find one
8
+ -- inside a line someone typed by hand.
9
+ --
10
+ -- These six columns carry the parts the fieldset now collects. They sit
11
+ -- BESIDE `address`, not instead of it: `address` remains the single-line
12
+ -- DISPLAY string, composed server-side from these columns when they are
13
+ -- written, and left exactly as the founder typed it when they are not.
14
+ --
15
+ -- Every column is nullable, and there is DELIBERATELY NO BACKFILL. Parts
16
+ -- compose into a line deterministically; a line does not decompose into
17
+ -- parts. Address parsing guesses, and a wrong guess — a region read as a
18
+ -- city, a house number read as a postcode — silently corrupts the party
19
+ -- identification a listing's privacy notice points at, with nothing on the
20
+ -- row to mark it as invented. A null part is honest; a guessed one is not.
21
+ -- So every existing row keeps its `address` string byte-for-byte and keeps
22
+ -- all six parts NULL, for the life of the row.
23
+ --
24
+ -- No index: every read of these columns is already keyed by the listing's own
25
+ -- primary key or by an existing (site_id, …) index, and no query filters on a
26
+ -- part.
27
+ ALTER TABLE "project_listings" ADD COLUMN "address_line1" TEXT;
28
+ ALTER TABLE "project_listings" ADD COLUMN "address_line2" TEXT;
29
+ ALTER TABLE "project_listings" ADD COLUMN "address_city" TEXT;
30
+ ALTER TABLE "project_listings" ADD COLUMN "address_region" TEXT;
31
+ ALTER TABLE "project_listings" ADD COLUMN "address_postal_code" TEXT;
32
+ ALTER TABLE "project_listings" ADD COLUMN "address_country" TEXT;
@@ -0,0 +1,71 @@
1
+ -- #5275 — ConsentGrant: one person's answer to one consent purpose, with the
2
+ -- exact wording they read. Mirrors the shape of
3
+ -- 20260907180000_add_listing_csv_send (CreateTable + CreateIndex immediately
4
+ -- followed by the ADR-0031 rule-3 id-format completion applied in the SAME
5
+ -- migration since the table is new, no retrofit needed), then the listing
6
+ -- BACKFILL.
7
+ --
8
+ -- The table is APPEND-ONLY (D9): no updated_at, and deliberately NO unique
9
+ -- constraint on (subject, purpose) — current state is the newest row per
10
+ -- pair, so a later withdrawal never erases the earlier grant. Declines are
11
+ -- stored as granted = false (D8). subject_type/subject_id are plain scalars
12
+ -- with no cross-namespace FK (D10, ADR 0009). statement is denormalized (D7).
13
+ --
14
+ -- BACKFILL: one row per pre-existing project_listings row (D11, spec §5.3
15
+ -- second boundary). Those founders ticked a consent box, but the system never
16
+ -- RENDERED any statement to them — the control was a select carrying one fake
17
+ -- option, and the client sent a hardcoded `consent: true` — so there is no
18
+ -- per-listing wording to recover. Each backfilled row is therefore marked
19
+ -- WORDING-UNRECOVERABLE rather than stamped with today's default statement:
20
+ -- stamping current wording onto a grant nobody read would fabricate evidence.
21
+ -- The sentinel is the exact `UNRECOVERABLE_CONSENT_STATEMENT` literal from
22
+ -- apps/api/src/services/consent.ts, its hash is COMPUTED here rather than
23
+ -- hand-typed, locale is BCP-47 `und` (undetermined), and created_at is the
24
+ -- listing's own consent_at — the one fact about the act that IS recoverable.
25
+ -- This migration never UPDATEs or ALTERs project_listings; consent_at is left
26
+ -- exactly as it was (spec §2.3).
27
+
28
+ -- CreateTable
29
+ CREATE TABLE "consent_grants" (
30
+ "id" TEXT NOT NULL DEFAULT ('cnsnt_'::text || (uuidv7())::text),
31
+ "site_id" TEXT NOT NULL,
32
+ "subject_type" TEXT NOT NULL,
33
+ "subject_id" TEXT NOT NULL,
34
+ "purpose_id" TEXT NOT NULL,
35
+ "granted" BOOLEAN NOT NULL,
36
+ "locale" TEXT NOT NULL,
37
+ "statement" TEXT NOT NULL,
38
+ "statement_hash" TEXT NOT NULL,
39
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
40
+
41
+ CONSTRAINT "consent_grants_pkey" PRIMARY KEY ("id")
42
+ );
43
+
44
+ -- CreateIndex
45
+ CREATE INDEX "consent_grants_site_id_subject_type_subject_id_idx" ON "consent_grants"("site_id", "subject_type", "subject_id");
46
+
47
+ -- CreateIndex
48
+ CREATE INDEX "consent_grants_site_id_purpose_id_idx" ON "consent_grants"("site_id", "purpose_id");
49
+
50
+ -- ADR-0031 rule 3 — consent_grants: cnsnt_ (registered in
51
+ -- packages/ids/src/registry.ts ENTITY_PREFIX.consentGrant; this migration's
52
+ -- path is added to migration-sync.test.ts's PK_CHECK_MIGRATIONS).
53
+ ALTER TABLE "consent_grants" ALTER COLUMN "id" TYPE text COLLATE "C";
54
+ ALTER TABLE "consent_grants" ALTER COLUMN "id" SET DEFAULT ('cnsnt_'::text || (uuidv7())::text);
55
+ ALTER TABLE "consent_grants" ADD CONSTRAINT "consent_grants_id_format" CHECK ("id" ~ '^cnsnt_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
56
+
57
+ -- Backfill (D11): one wording-unrecoverable `service_terms` grant per
58
+ -- pre-existing listing, stamped at the listing's own consent_at. Read-only
59
+ -- against project_listings.
60
+ INSERT INTO "consent_grants" ("site_id", "subject_type", "subject_id", "purpose_id", "granted", "locale", "statement", "statement_hash", "created_at")
61
+ SELECT
62
+ "site_id",
63
+ 'project_listing',
64
+ "id",
65
+ 'service_terms',
66
+ TRUE,
67
+ 'und',
68
+ '[unrecoverable: no statement was rendered by this system for this grant]',
69
+ encode(sha256(convert_to('[unrecoverable: no statement was rendered by this system for this grant]', 'UTF8')), 'hex'),
70
+ "consent_at"
71
+ FROM "project_listings";
@@ -0,0 +1,30 @@
1
+ -- The site's own answers store on `project_listings` (#5285).
2
+ --
3
+ -- The founder application used to couple the platform to a QUESTION: a step
4
+ -- kind rendered one control and wrote one column, so a site could not ask
5
+ -- anything of its own without an upstream release. The field contract names
6
+ -- the fields the platform actually READS — the ones that derive a slug,
7
+ -- render a card, key the concurrency limit, build the operator's issue — and
8
+ -- those keep their columns. Everything else a site wants to ask is editorial,
9
+ -- and lands here instead: one JSON object of `{ questionId: answer }`.
10
+ --
11
+ -- BOUNDED, not free-form. The write path is ANONYMOUS — a resume token and
12
+ -- nothing else — and an unbounded JSON bag on an anonymous surface is a place
13
+ -- for a stranger to park arbitrary text in the table. So the server accepts
14
+ -- only ids the site declared in its own `SiteListingConfig.data.siteAnswers`,
15
+ -- at that site's own count and length caps, and refuses the WHOLE request
16
+ -- otherwise: an answer for an id the site does not ask is a 400 and nothing
17
+ -- is written. What can land in this column is therefore never larger than the
18
+ -- questions an operator chose to ask.
19
+ --
20
+ -- Nullable with no default and no backfill: null means "this site asks
21
+ -- nothing of its own", which is every existing row and every site that has
22
+ -- declared no questions. An empty object would claim the founder was asked
23
+ -- and answered nothing, which is a different fact.
24
+ --
25
+ -- No index, and no GIN index in particular. Nothing queries INTO this column:
26
+ -- every read of it is already keyed by the listing's primary key or by an
27
+ -- existing (site_id, …) index, and an answer the platform searched on would
28
+ -- by definition be an answer the platform reads — which is what a contract
29
+ -- field and a column of its own are for.
30
+ ALTER TABLE "project_listings" ADD COLUMN "site_answers" JSONB;
@@ -0,0 +1,49 @@
1
+ -- The verified owner link on `project_listings` (#5295).
2
+ --
3
+ -- A listing carried `founder_email` and `lead_id` and no user link, so there
4
+ -- was no query for "the listings belonging to this signed-in person". The only
5
+ -- join available was a session's email against `founder_email` — and
6
+ -- `founder_email` is an UNVERIFIED string typed into an anonymous form, so that
7
+ -- join hands a stranger someone else's submission the first time two people
8
+ -- type the same address. These two columns are the ownership claim instead.
9
+ --
10
+ -- Plain scalars, no foreign key into `users`: ADR 0009 invariant #1 forbids a
11
+ -- relation across the namespace boundary, and `leads.promoted_user_id` /
12
+ -- `promoted_at` (20260714000000_add_lead_promotion_fields) are the same shape
13
+ -- for the same reason. The join is stable-id matching in the service layer.
14
+ --
15
+ -- `apps/api/src/services/listing-promotion.ts` is the sole writer, and it sets
16
+ -- the pair only when all three of these hold:
17
+ -- 1. the reconciling user's OWN email is verified (`users.email_verified`) —
18
+ -- never a claimed or session-asserted address;
19
+ -- 2. that verified address matches the row's `founder_email`;
20
+ -- 3. the row's `site_id` is one of that user's own tenant workspaces.
21
+ -- Any one failing writes nothing.
22
+ --
23
+ -- NO BACKFILL, deliberately and permanently. Every existing row predates
24
+ -- verification, so deriving `owner_user_id` from `founder_email` here would
25
+ -- assert exactly the link nobody proved — the bug the column exists to remove.
26
+ -- Existing rows stay null, and a listing submitted by someone who never signs
27
+ -- in stays null for the life of the row: that is a legitimate resting state,
28
+ -- not a backlog to clean up.
29
+ --
30
+ -- The index backs the self-scoped read's whole WHERE clause
31
+ -- (`GET /v1/project-listings/me` — `owner_user_id = $1`, and nothing else: the
32
+ -- owner link IS the scope, so no site term joins it). Single-column by design —
33
+ -- the promotion match (`founder_email` + `site_id` + a null owner) is already
34
+ -- served by the existing (site_id, founder_email) index.
35
+ --
36
+ -- Forward-additive only: both columns are nullable with no default, so every
37
+ -- existing read and write is unaffected.
38
+ --
39
+ -- Rollback:
40
+ -- DROP INDEX "project_listings_owner_user_id_idx";
41
+ -- ALTER TABLE "project_listings" DROP COLUMN "owner_linked_at";
42
+ -- ALTER TABLE "project_listings" DROP COLUMN "owner_user_id";
43
+
44
+ -- AlterTable
45
+ ALTER TABLE "project_listings" ADD COLUMN "owner_user_id" TEXT,
46
+ ADD COLUMN "owner_linked_at" TIMESTAMP(3);
47
+
48
+ -- CreateIndex
49
+ CREATE INDEX "project_listings_owner_user_id_idx" ON "project_listings"("owner_user_id");
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@working-theory/database",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "license": "Apache-2.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
File without changes