Skip to content

release: promote preview to main for PROD (2026-09-09) - #45

Merged
tobao17 merged 24 commits into
mainfrom
preview
Sep 8, 2026
Merged

tobao17 merged 24 commits into
mainfrom
preview

Conversation

@tobao17

@tobao17 tobao17 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Promote preview -> main for the PROD deploy of 2026-09-09.

Carries the 2026-09-08 UAT release (test -> preview) verified on app.drumee.com/-/preview/, plus whatever preview gained since the last promotion. Merge commit on purpose so main's own commits (docs, ci pin, cherry-picked fixes) are kept.

DB prerequisites are already on the shared prod database (schemas #175 applied and verified on 2026-09-08).

tranh0anghuan and others added 24 commits August 4, 2026 14:11
server-core logs a connection inside session.signin()/login() and nowhere
else, so a session opened by a PROCEDURE is invisible to everything that
reads services_log -- yp.show_login_log, and the analytics "Last login"
column, which takes MAX(ctime) over rows carrying args.success='1'.

Both OAuth paths open sessions that way and neither registered:

  handleOAuthCallback CASE A -- session_login_with_oauth signs the user in
  and writes no log row.

  oauth.verify_otp -- the 2FA leg. The callback returns at CASE D without
  logging, correctly, since the cookie is only otp_pending and nobody is
  signed in yet; session_login_otp then finalizes it and writes nothing. An
  OAuth account with 2FA signed in perfectly and never registered at all.

CASE C (new user) needs no equivalent: it signs up through create_account,
which finishes on session.signin() and is logged there.

This restores behaviour rather than inventing it. Stage still holds
google.callback and apple.callback rows, but none newer than 2025-11-18,
while yp.signin rows run to today -- the logging was lost when these paths
moved into this module.

The helper lives on the Account base both services extend, and cannot break
a login: the provider has already authenticated the user by the time it
runs, so a failure is warned and swallowed.

Co-authored-by: Drumee Dev <drumee@debian.local.drumee>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(mail): send from contact@drumee.org and stop the "Drumee>" display name

Two changes to the same header, because the first exposed the second.

The From used to come from credential/email.json's auth.user -- the transport's
SMTP login, an unmonitored butler@ mailbox -- and verify-email and
signup-completed passed no `from` at all, so they fell through to the package's
module-level default and arrived as a bare address with no display name while
the OTP mail beside them showed "Drumee". Pin the address in
service/lib/mail-sender.js and give all three a real From.

Passing that From through Messenger.send() is what produced "Drumee>". This
service pins @drumee/server-essentials ^1.2.29, and 1.2.29's send() does:

  let from = args.from || `butler@${domain}`;
  try { from = configs.auth.user; } catch (e) {}   // `configs` is not in scope
                                                   // here, so this always
                                                   // throws and ours stands
  ...
  from: `Drumee <${from}>`                         // wraps it a SECOND time

Given a full mailbox that yields `Drumee <"Drumee" <contact@drumee.org>>`, which
parsers read as { name: "Drumee>", address: "contact@drumee.org" }. The address
still resolves, so the mail delivers and the log says "Message sent" -- nothing
anywhere reports a malformed header.

Worth recording why this hid for so long: the plugin resolves its OWN bundled
copy of the package, not the 1.3.1 under runtime/server/main, and 1.3.1's send()
passes `from` through untouched. Reading the wrong copy cleared the code twice.

sendAs() drives the transport directly instead. Passing a bare address would
paper over it only while 1.2.x is installed -- the caret range also admits
1.3.x, where the bare address would arrive with no display name -- so this is
correct under both. It is the same approach analytics-server's _deliver() takes.
The transport is module-cached inside the package and is deliberately not
closed.

Verified end-to-end against the real 1.2.29 through the real relay, reading the
delivered message back over IMAP:

  before   From: "Drumee>" <contact@drumee.org>
  after    From: Drumee <contact@drumee.org>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style(mail): cap the loby email templates at 600px

The frame table carried width="100%" with max-width:100%, so the mail stretched
to whatever width the inbox pane happened to be and the body copy ran the full
width of a desktop screen. 600px is the standard email frame width and the width
the sibling templates in server-team already use.

Fluid below the cap, capped above. The width ATTRIBUTE has to stay a number for
Outlook, which ignores the style and treats width="100%" as a broken value;
centring comes from the align="center" already on the enclosing td.

Verified by rendering each template headless at 1200px and measuring the frame:
600px, with no horizontal page overflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Drumee Dev <drumee@debian.local.drumee>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(onboarding): key answers on uid, let any step create the row, true reset

Backend and schema half of the onboarding reliability fix. Pairs with
onboarding-ui fix/onboarding-flow-reliability — the save_* procedures gain a
_uid parameter, so both must ship together.

session_id was the only write key. A session is transient: it rotates on
re-login, token refresh and expiry, and keying durable survey answers on it
meant the answers became unreachable the moment it changed. Worse, only
save_onboarding_user_info could INSERT — every other step was a bare UPDATE
that raised "Onboarding session not found. Start at step 1." So a single
failed or late step-1 wedged the whole wizard permanently, and a mid-flow
re-login did the same.

Adds a nullable uid column (additive, indexed, non-unique) and routes every
procedure through onboarding_resolve_row, which resolves in a fixed order:
the row for this session (unchanged behaviour for every existing record, and
it stamps uid as it goes, so rows migrate themselves on first touch), else
the user's most recent row re-pointed at the new session, else a fresh row.
Steps can now create the row they write to, so one failure no longer poisons
the rest of the flow. mark_onboarding_complete still validates firstname /
industry / role / team_size, so a stub row can never pass as complete.

Empty tool and challenge selections now overwrite instead of being rejected:
save_onboarding_tools no longer throws on an empty array, and
check_onboarding_completion NULL-tests rather than length-tests, so
"none of these" is a real answer rather than an unanswered step.

The tools "Other" free text moves into its own tools_other column, matching
industry_other / role_other, instead of being spliced into the current_tools
JSON array where nothing could distinguish a canonical key from user input.
Normalisation runs inside the procedure, so a client posting the old inline
shape still produces a clean array plus a populated tools_other; an idempotent
backfill converts existing rows without touching clean ones or their mtime.

reset() cleared authorization and nothing else: it discarded the session but
kept the data, orphaning a row per reset and restarting the wizard against a
dead session. It now deletes the user's answers (including orphans left by the
old behaviour) and leaves the login alone.

Security: fast_check "public-api" short-circuits the ACL to GRANTED before src
is ever evaluated, so "src": "anonymous" on these services was unreachable
rather than merely permissive. User-data endpoints move to src "owner" with no
fast_check; get_env, get_countries and save_signup_info stay public. Every
handler also gained a service-level identity guard, which does not depend on
the ACL being configured correctly. update_profile validated identity AFTER
destructuring this.user's profile, throwing for anonymous callers instead of
returning the "no-user" answer two lines below; it now checks first and
resolves its row by uid before falling back to email. Several handlers tested
`!this.uid`, which is truthy for ID_NOBODY and let anonymous callers through.

Verified against a scratch MariaDB: all 14 files apply to both a simulated
pre-migration v2 table and a fresh install; session rotation, step-out-of-order
creation, empty-selection clearing, legacy tools normalisation, uid stamping
and reset all behave as intended, and the validation guards still reject bad
values.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(schema): relax lastname to NULL, repairing pre-existing drift

Caught by a smoke test against stage, which failed every write with
"Field 'lastname' doesn't have a default value".

tables/onboarding_responses.sql has declared lastname NULL since the v2
rework — it is collected at signup, not by the wizard — but instances created
from the v1 definition still carry NOT NULL, and alter_onboarding_responses_v2
never relaxed it. Stage is one of them.

Under STRICT_TRANS_TABLES (the server default there) that makes any INSERT not
naming lastname fail outright. It breaks onboarding_resolve_row's stub insert,
and it equally breaks the v2 wizard's own step 1, which posts firstname only —
so this was already a latent bug for new users on any drifted instance, not
something the uid work introduced.

Widening NOT NULL -> NULL cannot lose data and MODIFY is idempotent, so this
is a no-op on correct instances. Ordered before the procedures by the manifest.

Verified by reproducing the exact stage schema and sql_mode locally: the call
fails before the migration, succeeds after, the migration re-runs clean, and
the full wizard suite (out-of-order step, session rotation, empty selection,
tools "other", completion, reset) passes on the repaired table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(acl): revert onboarding to public-api reachability; auth stays in service

src:owner denied every onboarding call on stage with PERMISSION_DENIED:

  [onboarding.save_user_info][DENIED] to uid=650d04e8650d04ec
                                      on hub_id=cd1cbbc8cd1cbbdc, nid: undefined

The caller is authenticated - a real uid, not ID_NOBODY - so this is a
privilege failure, not an identity one. Onboarding requests carry no hub_id
(the payload is firstname + socket_id + device_id), so the ACL resolves them
against the ENDPOINT's hub rather than the caller's. src:owner then asks
whether a user midway through onboarding owns the endpoint hub. They never do.

My justification for src:owner was contact.invite, which uses exactly that
shape and is called successfully from this same wizard. That reasoning was
wrong: contact.invite passes hub_id: Visitor.id, so it resolves to the
caller's OWN hub, where they are the owner. Same src value, different subject.
There is no fast_check meaning "any authenticated user" (the options are
user_permission, guest_permission, socket_bound, public-api), and the
MFS-based path cannot express it either, so the ACL layer cannot carry this
requirement at all for a plugin mounted on its own hub.

Reachability therefore goes back to what worked, and authentication stays
where it is actually enforceable - _identity() in service/onboarding.js, which
rejects anonymous callers with 401 and keys every row on the caller's uid.
That guard was written to not depend on ACL configuration, which is exactly
the property needed here.

This is still stronger than the original: before, these endpoints had no
authentication whatever and any caller with a session id could write or read
another user's answers; update_profile also touched this.user before checking
identity. Those remain fixed.

Each service now carries a doc field recording why src:owner must not be
reintroduced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(changelog): restore truncated history; record lastname repair and ACL note

Two things.

First, a repair. The 2026-08-04 entry added in d6a3b40 was written with
`open(p,'w').write(entry + open(p).read())`, which truncates the file before
the read in the same expression evaluates — so the read returned empty and the
prepend replaced the changelog instead of extending it. All six prior entries
(2025-11-17 through 2026-07-20) were lost in that commit and are restored here
from test, verified byte-identical before re-prepending. The diff against test
is now additions only.

Second, the entry itself gains what was learned applying this patch to stage:

  - alter_onboarding_responses_identity.sql also repairs pre-existing drift,
    relaxing lastname from NOT NULL to NULL. Under STRICT_TRANS_TABLES that
    drift broke any insert not naming the column — the new stub insert, and
    already the v2 wizard's own step 1.
  - what was applied to stage, which database, and where the rollback set was
    left.
  - that acl/onboarding.json must keep fast_check public-api. src:owner was
    tried and denied every call: onboarding requests carry no hub_id, so the
    ACL resolves them against the endpoint's hub, which a user in onboarding
    never owns. Authentication lives in _identity() instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(onboarding): accept whitespace-padded email; stop the role dead-end

Two production failures from 6_8ebdc3818ebdc382. Neither was fixed by the
uid work — and the new no-advance-on-failure behaviour turned both from
silent data loss into a hard block, so they had to be resolved together.

1. "Invalid email format" on save_onboarding_user_info

     parameters: [..., 'exadim349@gmail.com ', '']
                                            ^ trailing space

   The format check is anchored (^...$) and nothing trimmed, so one stray
   space rejected an otherwise valid address. The address is never typed into
   the wizard — it is carried from signup or backfilled from the account
   profile — so the user had no field to correct and no way past step 1.
   Now normalised before validation, in the procedure and in the service.
   REGEXP_REPLACE rather than TRIM, because bare TRIM() strips spaces only and
   a tab or newline from a paste or import would still fail. Genuinely
   malformed addresses are still rejected; internal spaces are preserved.

2. "Step 3 (role) is incomplete." on mark_onboarding_complete

   The footer grouped step 2 (Role) with step 5 (Goals) and so offered it a
   "Tell me later", while mark_onboarding_complete treats role as mandatory —
   its own comment even claims "no Tell me later in UI for these steps", which
   this footer contradicted. Skipping role produced a wizard that could be
   walked to the end and then refused to complete. Previously the refusal was
   swallowed and the user was let into the workspace with onboarded unset;
   now it is surfaced, and the done screen has no Back button, so they would
   have been trapped on a screen whose only button fails forever.

   Fixed on both sides. Role no longer offers "Tell me later", matching the
   documented mandatory set (steps 4, 5 and 6 keep theirs). And a
   mark_complete refusal now routes to the first unanswered mandatory step
   with the reason shown there, so no future mismatch between the UI's
   skippable steps and the procedure's required ones can strand anyone.

The server contract is deliberately unchanged: role is still required, and
mark_onboarding_complete still refuses without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Drumee Dev <drumee@debian.local.drumee>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Setting onboarded = 1 is the New -> Onboarding transition on the analytics
Referral users board. That board polls every two minutes, so the row read
stale for up to that long after the user pressed the last button in the
wizard. It now turns over about a second later.

Published AFTER the profile write, never awaited, and it swallows its own
errors. Onboarding completion is the user's flow; an analytics push is a
bystander. A slow Redis or a dashboard nobody has open must not add latency to
update_profile, and must certainly not fail it — the profile is already
committed by the time the push runs, so a rejection would report failure for
work that succeeded. Publishing before the write would race its own commit and
send the status the user just left.

The row comes from referral_members, which owns the status CASE; nothing here
re-derives a status, so the live badge and the polled badge cannot disagree.
Asking that procedure for the row also serves as the cohort gate — it answers
nothing for a user who was never referred, and those are the majority.

Recipients come from referral_live_sockets (analytics-server schemas): every
active socket of every user permitted to read the analytics hub. That is the
same rule get_env gates on, and per-socket delivery means multiple open tabs
need no extra handling.

The mirror of this is server-team desk.track_workspace, which reports the
Onboarding -> Activated half of the same transition.

Co-authored-by: Drumee Dev <drumee@debian.local.drumee>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
All three user-facing mails went out as a lone text/html body. Nodemailer
does not synthesise a text part, so `html` with no `text` produces a
single-part message — a long-standing spam heuristic, and these are 2FA
codes and verification links, where landing in spam locks a user out of
signing in.

sendAs() now takes an optional `text`. The key is set only when truthy:
omitted, the message is byte-for-byte what it was before, so the contract
stays backward-compatible; an empty string would build an alternative
whose text/plain part is blank, which is worse than having none.

Plain-text bodies are hand-written at each call site from the same data
the template receives, not stripped from the rendered HTML — these are
nested layout tables whose text nodes are spacer &nbsp; and icon alt text
as often as they are prose.

Alongside that, three fixes to what the mails actually say:

- verify-email printed the full verification URL, token and all, as
  visible body copy beside the CTA — the shape a phishing template has.
  The fallback is now an anchor; the copyable URL moved to the text part,
  where a reader with no anchor to follow actually needs it.
- signup-completed emitted the recipient's own address on a line of its
  own above the copy. It now greets, matching verify-email.
- the OTP mail's subject and headline came from lexicon keys absent from
  the default lexicon. Cache.lex() returns the lexicon MAP, so a missing
  key reads as undefined (echoing the key name is Cache.message(), a
  different function) — on any box without a loaded lexicon both went out
  as the literal string "undefined". Guarded in the data, not the
  template, so the two MIME parts cannot fall back differently.

The OTP expiry line is derived from the row otp_create returns rather
than restated in the copy: the procedures disagree (authenticate.sql and
session_login_otp.sql expire at 10 minutes, check.sql at 30), and a mail
naming the wrong number is worse than one naming none.

Social badges now match analytics-server's claim-reward.html, whose
footer is the Figma "Email marketing" node and the canonical set. Three
of the five channels here were placeholders never corrected:
discord.gg/drumee is a dead invite (the API answers "Unknown Invite",
code 10006) and x.com/drumee is the wrong handle. Icons are sized per
asset — linkedin.png is 13x9, so the uniform 16x16 stretched it — and the
badge circle's rgba() became the flattened hex it needs to paint under
Outlook's Word engine.

OTP generation, validation and expiration, verification-token handling,
the signup flow, session behaviour and the sender address are untouched.
No List-Unsubscribe: these are transactional.

Deliverability is NOT fixed by this commit. drumee.org still softfails
SPF (the relay is in neither `a` nor `mx`) and publishes two DMARC
records, which RFC 7489 6.6.3 makes receivers discard entirely; whether
the relay signs d=drumee.org s=mail is unverified. Those are DNS and
relay-side changes — see the header of service/lib/mail-sender.js.

Tests: 23 assertions in offline/test, driving the real service methods
against real nodemailer MIME with only the DB, request input and
transport stubbed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(oauth): answer the browser on every apple.callback failure path

apple.callback had no error handling at all: no try/catch, and the
success branch guarded by `if (!res.error)` with no else. Apple posts
here (response_mode=form_post), so this is a top-level navigation, not an
XHR — every exit has to put something in front of the browser.

It did not. oauth_not_linked, invalid_state, a rejected token exchange, a
JWKS fetch failure, an unverified email, a malformed id_token: all of
them either fell out of the method having written nothing, or threw out
of an async handler. What the user saw was a hung request or a raw error
page, with no way back to sign-in.

Now mirrors google.callback, which already had all of this:

  - missing/invalid code   -> sendOauthError('access_denied')
                              (the usual cause is the user cancelling on
                              Apple's consent screen)
  - res.error              -> sendOauthError(res.error)
  - anything thrown        -> sendOauthError('oauth_failed')

getOAuthCode now gets its provider argument too, so the warning names
Apple instead of logging "undefined", and its quiet flag is set because
we send our own response — without it the helper writes a JSON error body
into what is a page navigation.

This is also what makes client-side handling of ?oauth_error= reachable
on the Apple path; until now there was no redirect to carry a reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(oauth): drop the debug leftovers from the Google/Apple services

Removed:

  - apple.js _getAppleProfile: this.debug("AAAA:151", userParam, payload,
    this.input.toJSON(), this.input.data()). This one mattered — `payload`
    is the VERIFIED Apple id_token, so every Apple sign-in wrote the
    user's email and provider sub into the server log, alongside the full
    request dump.
  - apple.js initiate: this.debug("AAAA:210", redirect_uri, authUrl),
    replaced with the same one-line "URL generated with state" trace
    google.initiate already emits, which is the part worth keeping.
  - apple.js handleAppleResponse: dead. Nothing calls it, it duplicates
    what _getAppleProfile does properly from the id_token, and its body
    was a console.log.
  - google.js: console.log("AAA:30Attr.state ", Attr.state) at module
    load. Attr was imported for that line alone, so the import goes too.

The "[Auth] Google/Apple Credentials loaded" lines stay — those are
real startup diagnostics.

No behaviour change beyond what is logged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Drumee Dev <drumee@debian.local.drumee>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
An empty value fell straight through to the enum check and raised
'Invalid intent value', so intent was the one onboarding answer that could
never be retracted: the wizard's "Tell me later" now records "no answer"
rather than walking past the step, and there was no statement that could
write one.

NULL, '' and whitespace all clear the column. A non-empty value is still
validated against the same five keys, and a padded valid value is accepted
instead of rejected.

Verified in a throwaway database against the real table and resolver:
'' / NULL / '   ' clear it, ' build_workflows ' stores build_workflows,
'not_a_real_goal' still raises 1644 and leaves the stored value untouched,
and no duplicate rows are created. Applied to stage (1_c1d86df0c1d86df7,
previous definition backed up to ~/proc-backup) and to the local instance.

Co-authored-by: Drumee Dev <drumee@debian.local.drumee>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(onboarding): store the invite step's answer on the response row

The invite step was the only one that persisted nothing. Its addresses went
out through contact/invite, which creates the contact and mails it, and
onboarding_responses kept no record — so a response could not distinguish
"invited nobody" from "invited four people" and the funnel export stopped one
column short of the end of the wizard.

Adds:

  * onboarding_responses.invites JSON, holding the addresses contact/invite
    ACCEPTED — not what the user staged in the UI
  * save_onboarding_invites, which writes the list whole. The client sends the
    complete set it has sent so far rather than a delta, so a user who invites
    one person and then two more ends with all three in one array. Blanks and
    repeats are dropped, the latter because a retry after a partial failure
    legitimately re-presents addresses the previous call already stored. An
    empty array is a real answer ("skipped without inviting anyone") and
    overwrites, as it does for tools and challenges.
  * onboarding.save_invites + its ACL entry, using `get` rather than `need` so
    that empty answer can be sent
  * the column in get_onboarding_response, and 'invites' in the service's JSON
    parse loop, so a resumed session reads it back as an array

No format check in the procedure: an address has already passed the service's
regex AND been accepted by contact/invite before it arrives, so a second,
differently-spelled rule could only ever disagree with the one that mattered.

Verified against the real table and resolver in a throwaway database: first
send stores both addresses, a second send accumulates the third, duplicates
and blanks and padding are dropped, an empty array and NULL both store [],
a JSON object raises 1644, and get_onboarding_response returns the column.

Existing installs need schemas/migrations/alter_onboarding_responses_invites.sql.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(patches): manifest and changelog for the invite-step patch set

Lists what an instance needs for onboarding.save_invites to work, in apply
order: the additive column first, then the procedure that writes it, then
get_onboarding_response, which now returns it so a resumed session reads it
back. save_onboarding_intent rides along — it is independent, but instances
other than stage have not had it, and without it "Tell me later" cannot
retract a goal.

The previous patch set is kept commented out rather than deleted: it is
already applied everywhere that has run the identity migration, and the note
about never listing tables/onboarding_responses.sql (it opens with DROP TABLE
IF EXISTS) is worth keeping in front of whoever edits this next.

Also drops the AFTER clause from the migration. Naming a neighbouring column
tied an additive patch to whichever earlier migrations an instance happened
to have run: applying it to an instance without challenge_note failed on the
position of a column whose position means nothing.

Applied to stage (1_c1d86df0c1d86df7): 148 existing rows intact, none
touched, column added and read back through get_onboarding_response.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Drumee Dev <drumee@debian.local.drumee>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(utm): carry utm_content through to the signup

The UTM builder writes utm_content on every link it makes and the click log
records it, but no capture point read it — so a campaign the content of which
was measurable on the click side was invisible on the signup side, and "which
post brought the signups" had no answer.

Four keys now, everywhere the three were: the landing capture, the signup
router, the payload the form posts, and both loby stages that thread and
persist it as profile.utm.

The clamp is unchanged (trim, 64 chars). No migration: drumee_utm holds
whatever object it is given, so a value stored before this simply has no
utm_content.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(oauth): attribute an OAuth signup to the campaign it arrived on

An OAuth signup on a campaign link was persisted with no utm at all and counted
as organic. The referral handle made the round trip out to Google and back; the
campaign did not, because only one of them had somewhere to wait.

Both initiate methods now park the tags on the oauth_state row beside `ref`,
and handleOAuthCallback reads them back out of the same SELECT — which is
already `SELECT s.*`, so the new columns arrive without a query change. CASE C
threads them onto the profile the way `ref` is threaded, and create_account
persists them as profile.utm.

_utmFromInput lives on the base both providers extend rather than being written
twice — same four keys and the same clamp as every other capture point in the
chain.

ATTRIBUTION IS BEST-EFFORT AND SIGNING IN IS NOT. The insert falls back to the
ref-only shape and then to the bare row, so an instance whose oauth_state has
not been patched still signs people in — it just cannot attribute them. That
is the same guard the ref column shipped with, extended a step.

Verified against stage: a state row carrying all four tags comes back out of
the callback's exact SELECT with every column populated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(signups): record every signup as an event

create_account writes one row to yp.signup_track once the account exists,
carrying the four utm tags, the referral handle and the auth method.

AFTER THE FACT AND SWALLOWED. A signup that was not tracked is a reporting
gap; a signup that failed because tracking threw is an outage. The account is
already real by the time this runs and nothing below depends on the write, so
a missing table, a missing column or a dead connection costs a row of
reporting and nothing else.

INSERT IGNORE against PRIMARY KEY (uid) rather than check-then-insert: this is
fire-and-forget and may be reached twice, and a check would race with itself
on exactly the retry it exists to survive.

The OAuth path names its provider, so `method` separates google/apple from
local. Without that every signup reads as 'local' and the OAuth split — the
one that was broken until this week — is invisible again, which is how it went
unnoticed the first time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Drumee Dev <drumee@debian.local.drumee>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
One call in update_profile, right after drumate_update_profile.

HERE AND NOT IN mark_complete(). mark_complete only VALIDATES that the
mandatory steps are stored — it can succeed and then be followed by a
failed update_profile, leaving a user who never got `onboarded = 1` and
meets the wizard again on next login. The profile write is the line that
actually ends onboarding, so the milestone belongs after it.

This is the only timestamp the stage will ever have.
drumate.profile.$.onboarded is a boolean and nothing else records when
the wizard was finished — which is why the funnel needs a row at all
rather than a derived query. Accounts that completed before this ships
are backfilled with their signup time and flagged approx=1.

Not awaited and never throws, exactly like _pushReferralLive beside it:
onboarding completion is the user's flow and an analytics row is a
bystander. Idempotent at the database — yp.funnel_milestone is keyed
(uid, milestone) — so a user who reruns the wizard keeps the timestamp
of their first completion.

Co-authored-by: Drumee Dev <drumee@debian.local.drumee>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(oauth): carry the visitor's destination across the provider bounce

initiate parks `dest` on oauth_state beside ref and utm_*; the callback reads it
back and appends it to the `home` URL it already builds. A signed-out visitor
who clicks a campaign CTA and signs in with Google or Apple now lands where the
link named instead of on a bare desk.

THE DESTINATION GOES ON THE URL, not into storage. The visitor may land on a
different deploy slot from the one they clicked (measured on stage: clicked
/-/huan/, landed /-/), and a fragment could never have reached this server
anyway. ui-team's billing-deep-link consume() already reads a destination off
the URL when storage has none — so nothing changes over there, which was worth
verifying rather than assuming.

ALL THREE SUCCESSFUL EXITS carry it: existing sign-in, new account (the welcome
card's CTA) and 2FA (through the OTP screen). Missing one would make the feature
work for some users and silently not for others.

_sanitiseDest IS A SHAPE CHECK THAT REBUILDS, not an escaping pass: one path,
four params, each matched against its own anchored regex, and the string
assembled again from what survived — so a value can only ever be one this
function could have written. An unknown param is REFUSED rather than dropped,
because honouring half a link written against a contract we do not have is how a
destination becomes a wrong one rather than an absent one. Run at both ends: the
row is data, and the code that builds a template's input is the code that has to
have checked it.

AND THE TEMPLATES NO LONGER INTERPOLATE THAT URL RAW. lib/loby.js renders with
lodash, whose equals-delimiter is the RAW one — the reverse of EJS — so
location.replace('<url>') put request-derived text straight into a JS string
literal on the page that runs immediately after authentication. Both landing
templates now emit it through JSON.stringify, and the new-account CTA through
the escaping delimiter. The sanitiser refusing quotes and the template escaping
are independent; either alone is one edit away from an XSS.

The initiate INSERT now degrades one column-group at a time (dest → utm → ref →
bare) rather than falling straight to the bare row: a database without `dest`
must still keep the campaign. Signing in never depends on any of it.

Two guards in the sanitiser — the character check and the 255 cap — cannot
currently decide anything, verified by mutation: with the path fixed and every
param bounded, the longest value it can emit is 125 chars. They are kept as
defence in depth, and the suite pins the PREMISE instead (every param regex is
anchored and quote-free), so adding a free-text param fails loudly at the moment
those guards start doing real work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(oauth): allow the recipient marker through the destination allowlist

_sanitiseDest refuses any param it does not recognise — deliberately, so a link
written against a contract this code does not have cannot be half-honoured. That
makes an omission here worse than elsewhere: a destination carrying the campaign
CTA's `for=<tag>` was rejected WHOLE, so the OAuth deep link stopped working
entirely rather than merely losing its marker.

Case-tolerant and normalised to lowercase on rebuild. The server only ever emits
lowercase and the dashboard compares case-insensitively, so refusing an
uppercased tag would kill an entire destination over something neither end cares
about.

An EMPTY `for=` reads as absent rather than malformed, like every other param:
stripping a value is no easier than stripping the whole param, and the marker is
a UX guard rather than a control either way. Stated as its own case because the
first version of that test asserted the opposite — my expectation was wrong, not
the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Drumee Dev <drumee@debian.local.drumee>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… code

A mobile client names itself on initiate; the state carries it as a
whitelisted suffix. The callback then verifies the provider identity,
parks the profile in oauth_handoff and answers a 302 to the app scheme
instead of signing anyone in. The app redeems the code with oauth.claim
over its own session, which has to be the session that started the flow,
so neither a phished authUrl nor a scheme squatter can finish a login.

Also: consume the state row on every callback exit, count OTP attempts
(5) in verify_otp, mint-then-prune in resend_otp so a failed send keeps
the code already in the inbox, log the connection for OAuth sign-ups,
escape the oauth-error redirect, and stop printing sids and emails.
iOS presents Apple's native sheet and hands the app an identity token
audienced to the app bundle id, not the Services ID the web flow uses.
apple.native_nonce mints a session-bound nonce; apple.native_signin
verifies the token (lib/apple-token.js: JWKS signature, issuer, exactly one
audience, expiry, verified email, nonce digest), spends the nonce and
completes the sign-in on the calling session through the same path as the
web callback. One bundle_id per deployment; without it the native services
answer credentials_missing and the web flow is untouched. The web verifier
now delegates to the same function, with a 10 s JWKS timeout.
make_default_folers created two hubs on every new account - "Internal
Workspace" (area 'private') and "External Workspace" (area 'share') - plus a
"Personal Workspace" folder. The two hubs are gone; a new account now starts
with the Personal folder alone and creates whatever else it wants, which is
the path the post-signup tutorial already walks it through.

Both signup paths go through this method (signup.create_account and the OAuth
account-link in Account), so this covers both.

What went with the hubs:

- Every signup drew TWO entities from the hub pool. Signups were a pool
  consumer nobody counted alongside the create-workspace button, and the pool
  is finite (yp.pickupEntity, refilled by the hubs factory).
- The createHub calls only WARNED on failure, so when the pool was empty
  signup still completed and the account was simply missing its workspaces -
  nothing shown to the user, no repair path. Stage accounts created between
  24 Aug and 3 Sep 2026 own no hubs at all for this reason.

mfs_make_dir touches no pool, so the Personal folder never had either problem,
which is why it is the one that stays.

A new account still has exactly one workspace row: desk._fetchWorkspaces
counts a home-root folder as a workspace (mapped to area 'personal'), so the
desk opens it and never reaches the "create your first workspace" screen.
Analytics is unaffected - referral_members.sql already excluded seeded
workspaces by design, so they never counted as activity.

createHub() is left in place but now has no caller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
release: promote test to preview (onboarding reliability + invites, native Apple sign-in, mobile hand-off, multipart mail, utm/promo)
@tobao17
tobao17 merged commit 3fe4368 into main Sep 8, 2026
5 of 6 checks passed
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.

4 participants