Skip to content

Connect calendars during onboarding with private preview and safe imports - #450

Merged
NewWorldVenture merged 0 commit into
claude/roadmap-implementation-ld8bonfrom
codex/strategy-onboarding-oauth-20260909
Sep 10, 2026
Merged

NewWorldVenture merged 0 commit into
claude/roadmap-implementation-ld8bonfrom
codex/strategy-onboarding-oauth-20260909

Conversation

@NewWorldVenture

Copy link
Copy Markdown
Owner

Onboarding previously offered paste/demo calendar imports and sent connected-calendar users out to the dashboard. This implements the W8 gap from MARKET_DOMINATION_AUDIT.md and the coordination claim in #421: configured Google/Microsoft buttons now return to the owned wizard, show a real private calendar preview and first brief, and import the reviewed appointments at Finish.

  • Consent reads calendars and identity. The account is created atomically with a paused onboarding marker and manual direction. Every generic and legacy sync entry re-reads persisted policy before token, provider or job work. Finish enables calendar imports; it never enables calendar/task exports or task imports for these accounts.
  • Encrypted continuation and preview receipts bind the signed-in user, family, provider, account, calendar and event content. Role, active family and unfinished setup are rechecked. A completed wizard can resume only while its owner's paused onboarding connection still needs activation.
  • Imports cover the explicitly labeled primary calendar and next 30 family-local days, with recurring occurrences expanded, complete pagination and visible failures at limits. Microsoft all-day dates are resolved in their original timezone; first briefs use the family timezone.
  • Finish and later imports share the existing calendar_events materializer and stable provider identities. Replayed or changed previews preserve one managed member/invite and one event copy. Local edits/deletions are preserved. Only explicit cancellation tombstones delete untouched rows; an event absent from a bounded read is retained. Orphaned mappings cannot establish a deletion baseline.
  • New UI and policy messages are translated in all seven locales. No migration or shared sidebar change.

Validation: the full native-Windows suite passed 1,003 files / 9,623 tests, with the same five pre-existing Windows path failures across household-trail, i18n-server-boundary (two), supabase-query-audit and untrusted-fence-ratchet. Subsequent provider/date and cancellation-recovery regressions passed 93 targeted tests; the final status change passed another 37 targeted tests. Typecheck, changed-file ESLint, i18n gate and diff check passed.

Provider exchange/read behavior is tested through real routes, actions, services and engines with mocked provider/DB boundaries. No live provider account was exercised. Buttons require existing provider credentials and SYNC_TOKEN_KEY; Apple remains on its existing CalDAV path. The existing legacy membership editors are recorded in the service-layer ratchet now that onboarding owner provisioning has a service writer.

@vercel

vercel Bot commented Sep 9, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
bubaly Ready Ready Preview Sep 9, 2026 10:18pm UTC

Request Review

@NewWorldVenture
NewWorldVenture marked this pull request as ready for review September 9, 2026 22:29
@NewWorldVenture
NewWorldVenture force-pushed the claude/roadmap-implementation-ld8bon branch 3 times, most recently from 11aa925 to 677e44c Compare September 10, 2026 16:31
@NewWorldVenture
NewWorldVenture merged commit 35befc1 into claude/roadmap-implementation-ld8bon Sep 10, 2026
7 checks passed
@NewWorldVenture
NewWorldVenture force-pushed the claude/roadmap-implementation-ld8bon branch from 56d8dd0 to 404d5d5 Compare September 10, 2026 20:53
NewWorldVenture pushed a commit that referenced this pull request Sep 13, 2026
Found by crawling the internal links on the live site. The homepage and /pricing
both render a section headed "Family stories", introducing what follows as
quotes "In the family's own words". What followed were three database seed rows:

  Seed Case Studies #450   Seed Summary value 450   Seed Result Metric value 450
  Seed Case Studies #180   Seed Summary value 180   Seed Result Metric value 180
  Seed Case Studies #90    Seed Summary value 90    Seed Result Metric value 90

Each linked to a detail page answering 200 with <h1>Seed Case Studies #450</h1>
at /customers/seed-case_studies-450.

Same class as the sitemap defect on this branch — seeded rows reaching a public
surface — but worse in kind. That one published dead links; this publishes
invented customers under a heading that states they are real families.

is_published could never have caught it: the seeder sets it true, which is what
made them public. The rows are identifiable only by the seeder's slug
convention, `seed-<table>-<n>`.

isSyntheticSeedSlug() therefore joins publishedOnly() in the shared reputation
helpers, so both pages' list reader excludes them, and loadPublishedCaseStudy()
refuses one BEFORE touching the database — a link surviving in a cache or a
search index must not still render one. The list and the detail page agreeing is
the entire point: the sitemap defect existed precisely because two readers of
the same content did not agree.

Deliberately narrow, and both halves pinned:

  - testimonials have no `slug` column, so they pass through untouched. The
    predicate takes null/undefined and answers false.
  - the marker must be at the START of the slug, so a genuine story called
    `seeds-of-change` or `seed-starting-with-kids` is not swept up.

Verified non-vacuous: without the filter, `keeps seeded rows out of the public
list` and `404s a seeded story on its detail page without querying` both fail.
The existing marketing-reputation and customer-story suites pass unchanged.

The rows themselves remain in the database. The site no longer renders them,
which is the visible outcome, but deleting them is an admin action this session
has no credentials for; finalaudit.md records that.

tsc clean, lint 0 errors, 1149 files / 13098 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRY21ZKsFrfB3qtoP972A4
NewWorldVenture added a commit that referenced this pull request Sep 13, 2026
* Stop the sitemap advertising 435 URLs that 404, and name every gated surface in robots

An audit of the live site found the sitemap carrying 1,508 URLs of which 435 —
29% — answered 404. All of them were `/blog/Seed <uuid>`, a slug shape that also
carries a literal space and so is not a valid <loc> either.

Sampling was unambiguous: 8 of 8 seed URLs returned 404, 8 of 8 real ones 200.

The cause is two halves of one system disagreeing about what exists. Blog
entries come from getAllPosts(), which filters through publicRows ->
isSyntheticBlogSeedSlug; that filter is correct and catches 435 of the 435 live
slugs. But the sitemap ALSO emits rows from `marketing_pages`, which stores a
path directly and had no seed filter of any kind, so any published row went out
verbatim. The renderer meanwhile refuses them outright — getPost() opens with
`if (isSyntheticBlogSeedSlug(slug)) return undefined` and the page calls
notFound(). So one table advertised what the other would not render.

The file's own comment already stated the rule it was breaking: "a sitemap must
never point at a 404", written for the benchmarks entry directly above.

isAdvertisablePlatformPath() now applies the SAME predicate the renderer uses,
so the two cannot drift apart again, and rejects a non-rooted path as well.
canonicalSitemapUrl() collapses a stored root of `/` onto the static spelling
`''` — the dedupe keys on the URL string, so the homepage was published twice,
as `…com` and `…com/`.

WHY THIS SHIPPED UNDER A GREEN TEST. tests/marketing-sitemap-closed-loop.test.ts
— the file named for this exact property — never ran the sitemap. All three
cases read app/sitemap.ts as TEXT and asserted substrings appeared in it. A grep
cannot observe what a function emits. Demonstrated rather than argued: against
the unfixed code all three source cases still pass while the new behavioural
ones fail. They are kept (they do pin real wiring) and five cases added that
execute sitemap() against a stubbed database and assert on the URLs returned.
The stub is a Proxy rather than a fixed method list, so adding a .limit() or
.range() to either query cannot quietly turn the suite into a false pass.

Separately, robots.ts carried the comment "Authenticated app surfaces should not
be indexed" above a list of four. The authenticated route group holds 22
segments, and production confirmed 20 gated surfaces missing from that list —
/admin and its 80 pages among them — each answering 307 to /login.

That is NOT a data leak and is not claimed as one: every one of them redirects
while signed out. What was wrong is that the stated policy and the
implementation disagreed, crawl budget was spent discovering them, and a future
route answering 200 anonymously would have inherited no protection at all. A new
test derives the segment list from app/(app) on disk so it cannot fall behind,
and asserts the public marketing routes stay allowed — /family-display is public
while /family and /display are not, so a prefix rule would have taken it down
with them.

Also: page titles doubled the brand. Live: `Security & Privacy — Bubaly · Bubaly`
and `Contact Bubaly · Bubaly`. The titles come from the admin SEO store, not the
catalogue (0 catalogue strings end that way), so correcting the rows would only
last until someone typed another one. The layout template owns the brand, so a
self-branded title now opts out of it via Next's `absolute`. Only a TRAILING
brand counts — "Bubaly — The AI Family Operating System" still wants the suffix —
and the separator boundary keeps "Meet the Bubalys" untouched.

finalaudit.md records all of this, including what was checked and found healthy,
one finding I withdrew after re-checking (a missing-metadata reading that was my
own extraction bug), and the measured i18n payload problem left open for a
decision rather than fixed blind.

tsc clean, lint 0 errors, i18n gate clean, 1148 files / 13091 tests, build
exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRY21ZKsFrfB3qtoP972A4

* Stop presenting seeded rows as real customer stories

Found by crawling the internal links on the live site. The homepage and /pricing
both render a section headed "Family stories", introducing what follows as
quotes "In the family's own words". What followed were three database seed rows:

  Seed Case Studies #450   Seed Summary value 450   Seed Result Metric value 450
  Seed Case Studies #180   Seed Summary value 180   Seed Result Metric value 180
  Seed Case Studies #90    Seed Summary value 90    Seed Result Metric value 90

Each linked to a detail page answering 200 with <h1>Seed Case Studies #450</h1>
at /customers/seed-case_studies-450.

Same class as the sitemap defect on this branch — seeded rows reaching a public
surface — but worse in kind. That one published dead links; this publishes
invented customers under a heading that states they are real families.

is_published could never have caught it: the seeder sets it true, which is what
made them public. The rows are identifiable only by the seeder's slug
convention, `seed-<table>-<n>`.

isSyntheticSeedSlug() therefore joins publishedOnly() in the shared reputation
helpers, so both pages' list reader excludes them, and loadPublishedCaseStudy()
refuses one BEFORE touching the database — a link surviving in a cache or a
search index must not still render one. The list and the detail page agreeing is
the entire point: the sitemap defect existed precisely because two readers of
the same content did not agree.

Deliberately narrow, and both halves pinned:

  - testimonials have no `slug` column, so they pass through untouched. The
    predicate takes null/undefined and answers false.
  - the marker must be at the START of the slug, so a genuine story called
    `seeds-of-change` or `seed-starting-with-kids` is not swept up.

Verified non-vacuous: without the filter, `keeps seeded rows out of the public
list` and `404s a seeded story on its detail page without querying` both fail.
The existing marketing-reputation and customer-story suites pass unchanged.

The rows themselves remain in the database. The site no longer renders them,
which is the visible outcome, but deleting them is an admin action this session
has no credentials for; finalaudit.md records that.

tsc clean, lint 0 errors, 1149 files / 13098 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRY21ZKsFrfB3qtoP972A4

* Give five public pages the h1 they never had

Verified against production: /faq, /contact, /mobile and /family-display each
returned ZERO <h1> elements and began their document outline at <h2>.

All four used SectionHeading for their PAGE title, and that component hard-coded
h2. Pages with a hand-rolled hero (/, /pricing, /features) render exactly one h1
and were fine — which is why this only ever hit the pages that reused the shared
component. A missing h1 costs screen-reader users their primary way of orienting
on a page, and drops the strongest on-page relevance signal for search.

SectionHeading now takes an optional `as="h1"`, defaulting to h2 so every
existing section heading is untouched. The class list is identical at either
level and a test asserts that: this is a document-outline change and those pages
must look exactly as they did.

THE FIFTH PAGE. The structural test caught /resources/benchmarks, which probing
the live site never could — it answers 404 while its admin publication flag is
off. Same defect, same fix, found only because the check reads the tree rather
than the deployment.

A VACUOUS TEST, CAUGHT. The first version of the page sweep passed with the fix
reverted. The JSDoc I had just written on SectionHeading contains the literal
`as="h1"` as an instruction to callers, so every page importing that module
matched on the COMMENT rather than on its own markup. Found by reverting /faq
and watching the test stay green — the same failure as the sitemap closed-loop
test earlier on this branch, which is sharp enough twice to be worth writing
down. The sweep strips comments before scanning now, and reverting /faq
correctly fails and names it.

The sweep also resolves each page's local imports one level deep. Without that
it reported /pricing, /privacy, /terms and others as missing an h1 when their
hero simply lives in a sibling component — all three were confirmed live to
render exactly one.

tsc clean, lint 0 errors, i18n gate clean, 1150 files / 13102 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRY21ZKsFrfB3qtoP972A4

* Close the audit: finalaudit.md records every finding and its owner

Thirteen findings. Seven fixed and shipped on this branch, one disproved and
withdrawn, five closed as decisions or assigned to someone with access this
session does not have. Every remaining item has an owner and a next step.

The substantial work in this commit is the investigation behind F9 — the whole
848 KB catalogue serialized into every page — which is now closed as a decision
rather than left as an open question.

Measured what the public site actually needs, as a transitive import closure
per route group:

  marketing    81 client files    324 keys    21 KB   2.6% of the catalogue
  auth         62 client files     89 keys     5 KB   0.6%
  signed-in  1122 client files   8282 keys   442 KB  55.7%

So the marketing site ships 793 KB to deliver 21 KB of value.

Then established that pruning marketing and auth is SAFE rather than assuming
it: 9 dynamic `t(variable)` call sites across both groups, every one reading
from a static module inside the closure — MARKETING_NAV, VALUE_ROWS, the consent
categories, the local LINKS map in legal-consent — none from the database. 36 of
36 keys reachable through them are in the computed set; zero missing.

And disproved the tempting shortcut. A single global client-only subset needs no
path detection and saves 40%, and it is WRONG: 123 catalogue keys are defined as
literals in server components and handed to client components as props —
admins.tab.users, supportTickets.tab.open, runHistory.filterActive,
displaySetup.stepInstallTitle and 119 more. Every one would have rendered as a
raw key in front of a person. Found by scanning for it rather than by shipping
it.

What is left is the delivery mechanism, and all of it turns on verification I
cannot do here. Scoping by request path needs a header from middleware, whose
own comment documents that mishandling its request/response pair makes Supabase
treat a reused refresh token as stolen and revoke the entire session family.
Per-group root layouts would be clean but mean deleting app/layout.tsx and
moving eight ungrouped routes. Both risk surfaces are client-side hydration, and
no browser runs in this environment — Chromium dies in the proxy relay for every
host including google.com, which the document states plainly so nothing in it is
over-read.

The scaffolding written while proving this out — a scope generator, a scope
module and a generated key manifest — is deliberately NOT committed. It is not
wired to anything, and unused machinery in a repo is a claim that something is
handled when it is not.

Two process notes are recorded rather than quietly corrected: a finding I
withdrew after re-checking (a missing-metadata reading that was my own
extraction bug), and a vacuous test I caught in the fix for F11 — it passed with
the fix reverted because the JSDoc under test contains the literal it grepped
for, which is the same failure as F4.

tsc clean, 1150 files / 13102 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRY21ZKsFrfB3qtoP972A4

* Record the sitemap finding this audit's method could not see

A parallel session raised it on #526; verified independently before recording:

    GET /blog?category=Parenting   -> 200
         <link rel="canonical" href="https://www.bubaly.com/blog"/>

app/sitemap.ts emits one entry per blog category, nine of them. Each answers 200
and then names /blog as its canonical, so the sitemap asks Google to index URLs
the pages themselves declare are not the canonical version. The entries are
dropped and the crawl budget is spent anyway.

Same family as F1 — the sitemap claiming something the site does not support —
and squarely in this audit's scope. I missed it because I checked whether every
sitemap URL RESOLVED, which they do, and never checked whether it was
INDEXABLE. That is a gap in the method, not only in the sitemap, so it is
written down as one.

Not fixed here on purpose. #526 rewrites the same file with a broader
canonicalUrl() — trailing-slash normalisation, per-segment percent-encoding, and
refusal of query strings, fragments, non-relative paths and anything robots.txt
disallows, reading that disallow list from robots.ts so the two cannot drift.
That is a better fix than a second patch of mine, and two PRs rewriting
app/sitemap.ts in different directions helps nobody.

The overlap is stated plainly in the document rather than left for whoever
merges second to discover: #526 also found F1 and F3 independently. Its URL
hygiene is broader; the one property worth carrying across is that
isAdvertisablePlatformPath() applies the SAME predicate the renderer uses, so
the sitemap and the blog page cannot drift apart again — which is the root cause
F1 actually had. Nothing else on this branch touches #526's files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRY21ZKsFrfB3qtoP972A4

* Sharpen two findings on re-examination, and close the dependency question

F7 was understated. I had recorded "a Free family is given an address it cannot
see". Tracing the whole path found the gate exists ONLY on the screen:

  Contact Center screen     requirePlanLevel(2)  — Family+
  provisioning at onboarding   none — every tier
  inbound webhook              none
  lib/contact-center/*         none

The webhook resolves the family from the recipient local-part and proceeds. So a
Free family has a working @bubaly.com address that receives mail, files it, runs
the AI concierge over it and auto-replies AS the family, while the product
surface says Family+. The only thing they cannot do is look at the inbox.

That cuts both ways, which is why it stays a decision rather than getting a
default: either Free families are consuming AI inference and sending mail under
the product's domain ungated, or the screen's requirePlanLevel(2) is the thing
that is wrong. Gating the route would also start dropping mail for any Free
address already in circulation, so the ordering matters and is written down.

Dependency security is closed rather than open, and checked rather than assumed.
npm audit reports 8 advisories; every package was resolved against the
production tree and NONE of them appear in it — js-yaml, tar, brace-expansion,
browserslist, @xmldom/xmldom, vitest, @vitest/mocker, baseline-browser-mapping
are all build and test tooling. `npm audit --omit=dev --audit-level=moderate`,
the exact command CI runs, reports 0. There is no site finding here, so it is
recorded as closed with the evidence instead of sitting on a list.

F9 gains a third mechanism that is better than both I had, and removes the
objection I raised against them. A nested provider needs no middleware change
and no app-router restructure: the root layout keeps LocaleProvider with only
the keys routes outside app/(app) can reach, and app/(app)/layout.tsx — which
already exists — renders a nested one carrying the full catalogue, whose context
wins for signed-in pages.

Measured: the root set is 702 keys, 44 KB of 793 KB. Public pages drop ~94%;
signed-in pages carry 44 KB extra, behind a login. Completeness leaves exactly
three uncovered keys, all marketingDisplay.* in family-display/page.tsx, defined
in the server page and passed to a client component — the same pattern that
makes a global subset unsafe, but three keys in one file rather than 123 across
the admin console.

Still not shipped, and the reason is unchanged and specific: the blast radius is
every public page and this environment cannot render one. What is different is
that the remaining risk is now "is a key missing", which a regeneration test
settles exhaustively without a browser — so whoever picks it up has a mechanism,
a number, and three named gaps rather than a research task.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRY21ZKsFrfB3qtoP972A4

* Put the audit document in reading order

F14 was appended after "Verified healthy" because it was recorded last, which
put a finding after the section that says what had no findings. It now sits with
F1-F13 where a reader looks for it.

Three headings disagreed with their own status rows after the items were
reassigned this round — F5 and F6 to operator ownership, F12 to closed. The
heading and the summary table now say the same thing about each.

No content changed; this is the deliverable and it should read as one document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRY21ZKsFrfB3qtoP972A4

---------

Co-authored-by: Claude <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — 35befc11 Deployed Sep 9, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant