Skip to content

feat(labeler): notification contact-state foundation (W10.4 slice B)#2068

Merged
ascorbic merged 5 commits into
feat/plugin-registry-labelling-servicefrom
feat/labeler-notification-contacts
Jul 16, 2026
Merged

feat(labeler): notification contact-state foundation (W10.4 slice B)#2068
ascorbic merged 5 commits into
feat/plugin-registry-labelling-servicefrom
feat/labeler-notification-contacts

Conversation

@ascorbic

@ascorbic ascorbic commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Implements W10.4 slice B — the contact-state foundation for publisher notifications, per the ratified W10.4 decisions paragraph (integration 0a2eba12). Part of the plugin-registry labelling service (umbrella #1909, RFC #694). This slice is the binding-independent half: the opt-in/suppression state and hashing; the contact resolver (slice A) follows the W8.4 slice-3 aggregator binding, and delivery/sending is W10.5.

What lands

  • Migration 0007: notification_contacts (recipient-hash PK; confirm_state unconfirmed/confirmed/declined; single-use confirm_token_hash; last_confirm_sent_at_epoch_ms as the confirmation rate-limit gate) and notification_suppressions (bounce/complaint/unsubscribe/not_me; earliest reason retained). Both tables are hash-keyed point lookups — no plaintext address column exists by construction (schema-asserted in tests); plaintext will live only on the future W10.5 delivery row. Header comments distinguish these from the operator notification_outbox alert subsystem, reciprocally to migration 0005's.
  • Recipient hashing: HMAC-SHA256 (WebCrypto, workerd-native) over lowercase+trimmed email with a secret pepper (NOTIFICATION_HASH_PEPPER, wired into wrangler.jsonc required secrets following the signing-key pattern); 64-char lowercase hex. Normalization is deliberately minimal per the ratified decision — provider-style dot/plus variants do not collide (tested).
  • Query layer (raw D1, assessment-store idiom): contact state machine (ensureContactrecordConfirmSentconfirmContact/declineContact), suppression (isSuppressed/suppress, idempotent), and the pure canSendConfirm rate-gate predicate. confirmContact does a constant-time token-hash comparison and a conditional UPDATE gated on the stored hash (closes the read-then-write race); the token clears on success (single-use).

Tests

20 new (suite 573→593 workers; console 100 and calibration 39 unchanged): hash determinism/normalization/pepper-sensitivity, the full confirm state machine including wrong-token rejection and token single-use, suppression idempotency and earliest-reason retention, rate-gate boundaries, and the no-plaintext schema assertion.

Type of change

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable)
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.8 (implementation; orchestrated via Claude Code)

Screenshots / test output

Tests: 593 workers + 100 console + 39 calibration = 732 passed

Try this PR

Open a fresh playground →

A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.

Tracks feat/labeler-notification-contacts. Updated automatically when the playground redeploys.

@changeset-bot

changeset-bot Bot commented Jul 16, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: aefdcdc

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions github-actions Bot added review/needs-review No maintainer or bot review yet size/XL labels Jul 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 1,144 lines across 6 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 16, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-playground aefdcdc Jul 16 2026, 08:00 AM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 16, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-do aefdcdc Jul 16 2026, 08:00 AM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 16, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-cache aefdcdc Jul 16 2026, 08:00 AM

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is the right shape for W10.4 slice B: a focused, binding-independent foundation for publisher notification contacts. The migration keeps plaintext emails out of the schema by construction, the HMAC-SHA256 hashing mirrors the existing signing-runtime.ts pattern, and the tests cover the full state machine, suppression idempotency, rate-gate boundaries, and schema assertion. No regressions in the core labeler code paths.

What I checked:

  • Migration 0007 schema, CHECK constraints, and absence of plaintext/address columns.
  • src/notification-contacts.ts hashing, contact/suppression primitives, and D1 parameter binding.
  • Test coverage and miniflare binding configuration.
  • wrangler.jsonc secret declaration and generated worker-configuration.d.ts.

Headline conclusion: the implementation is clean and the slice is mergeable. I have a few non-blocking suggestions to harden the state machine and remove small future footguns.

One note on diff noise: worker-configuration.d.ts was regenerated with a newer workerd runtime version (1.20260611.11.20260708.1), so most of that file’s diff is unrelated API churn. The meaningful change is the new NOTIFICATION_HASH_PEPPER binding and ProcessEnv pick update. Consider regenerating with the pinned workerd version if that diff is undesirable, though it has no runtime effect given the unchanged compatibility_date.

Changeset: n/a — @emdash-cms/labeler is private.

Comment on lines +124 to +127
.prepare(
`UPDATE notification_contacts
SET confirm_token_hash = ?, last_confirm_sent_at_epoch_ms = ?
WHERE recipient_hash = ?`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] recordConfirmSent updates confirm_token_hash for any row matching the recipient hash, regardless of confirm_state. In the documented flow callers gate on canSendConfirm, which only permits unconfirmed contacts, but adding AND confirm_state = 'unconfirmed' hardens the primitive against a buggy caller that skips the predicate. ```suggestion
UPDATE notification_contacts SET confirm_token_hash = ?, last_confirm_sent_at_epoch_ms = ? WHERE recipient_hash = ? AND confirm_state = 'unconfirmed',

Comment on lines +152 to +155
.prepare(
`UPDATE notification_contacts
SET confirm_state = 'confirmed', confirm_token_hash = NULL, confirmed_at = ?
WHERE recipient_hash = ? AND confirm_token_hash = ?`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] confirmContact verifes the token hash but not that the contact is still unconfirmed. Defending the state machine at the UPDATE means a stale token on a declined or already-confirmed row cannot be replayed to flip the state. ```suggestion
UPDATE notification_contacts SET confirm_state = 'confirmed', confirm_token_hash = NULL, confirmed_at = ? WHERE recipient_hash = ? AND confirm_state = 'unconfirmed' AND confirm_token_hash = ?,

VALUES (?, ?, ?, ?)
ON CONFLICT(recipient_hash) DO NOTHING`,
)
.bind(recipientHashValue, reason, nowIso, Date.parse(nowIso))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] suppress derives created_at_epoch_ms via Date.parse(nowIso), which returns NaN for an invalid ISO string. A bad caller string could silently write a 0/NaN epoch or fail at runtime depending on the D1/SQLite behavior. Since other helpers like recordConfirmSent already take an explicit epochMs, consider passing it here too (or at least asserting Number.isFinite(epochMs)). ```suggestion
export async function suppress(
db: D1Database,
recipientHashValue: string,
reason: SuppressionReason,
nowIso: string,
nowEpochMs: number,
): Promise {
await db
.prepare(
INSERT INTO notification_suppressions (recipient_hash, reason, created_at, created_at_epoch_ms) VALUES (?, ?, ?, ?) ON CONFLICT(recipient_hash) DO NOTHING,
)
.bind(recipientHashValue, reason, nowIso, nowEpochMs)
.run();
}

* so it runs unchanged on workerd.
*/
export async function recipientHash(pepper: string, email: string): Promise<string> {
const key = await crypto.subtle.importKey(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] recipientHash imports the pepper into a CryptoKey on every call. For W10.5 batch hashing this wastes CPU on identical importKey work. The mapping from pepper string to CryptoKey is deterministic and the key is non-extractable, so caching it in a module-level Map is safe. ```suggestion
const keyCache = new Map<string, CryptoKey>();

export async function recipientHash(pepper: string, email: string): Promise {
let key = keyCache.get(pepper);
if (!key) {
key = await crypto.subtle.importKey(
"raw",
encoder.encode(pepper),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
keyCache.set(pepper, key);
}
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(email.trim().toLowerCase()));
return toHex(signature);
}

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Jul 16, 2026
ascorbic added 2 commits July 16, 2026 08:26
Add the opt-in and suppression state for publisher notifications:
`notification_contacts` (confirm state machine with single-use,
constant-time-compared token hashes and a confirmation rate-limit
timestamp) and `notification_suppressions` (bounce/complaint/
unsubscribe/not-me, earliest reason retained), plus the peppered
HMAC-SHA256 recipient-hash helper and a typed query layer. Both tables
key on the recipient hash only — no plaintext address column exists;
plaintext will live solely on the future W10.5 delivery row. Distinct
from the operator `notification_outbox` alert subsystem.
- recordConfirmSent/confirmContact gate the UPDATE on confirm_state =
  'unconfirmed', so a confirmed or declined row is never reopened and a
  stale token can never flip it; recordConfirmSent returns whether a row
  was touched.
- suppress takes an explicit finite epochMs instead of re-parsing the ISO
  string.
- recipientHash caches the imported HMAC key per pepper on globalThis.
@ascorbic
ascorbic force-pushed the feat/labeler-notification-contacts branch from 28bb187 to 6917a83 Compare July 16, 2026 07:35
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jul 16, 2026
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jul 16, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds the W10.4 slice B contact-state foundation: HMAC-SHA256 recipient hashing, the double-opt-in state machine, a suppression ledger, and a rate gate. The approach is sound, matches the existing signing-runtime.ts secret-binding pattern, and keeps plaintext out of the schema by construction. Tests cover hash determinism/pepper-sensitivity, token single-use, suppression idempotency, rate-gate boundaries, and the no-plaintext schema assertion.

The prior review found the slice mergeable with a few non-blocking hardening suggestions. On re-reviewing the current head, I found one of those state-machine footguns is an actual gap that should be fixed before callers rely on it, plus two minor hardening notes.

What I checked:

  • Migration 0007 schema, CHECK constraints, and absence of plaintext address/email columns.
  • src/notification-contacts.ts hashing, HMAC key cache, state machine, suppression, and D1 parameter binding.
  • wrangler.jsonc / worker-configuration.d.ts secret wiring (the workerd version churn noted in the prior review appears addressed: the regenerated file is now at the pinned 1.20260611.1 version).
  • vitest.config.ts binding and test coverage.

Headline conclusion: the slice is fundamentally clean and the right shape for W10.4. The one fix worth making now is adding the missing unconfirmed state guard to declineContact so the state machine is consistent with confirmContact and recordConfirmSent.

Comment on lines +190 to +196
export async function declineContact(db: D1Database, recipientHashValue: string): Promise<void> {
await db
.prepare(
`UPDATE notification_contacts
SET confirm_state = 'declined', confirm_token_hash = NULL
WHERE recipient_hash = ?`,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] declineContact currently downgrades a contact to declined regardless of its current state. The migration describes the state machine as unconfirmed -> confirmed | declined, and both recordConfirmSent and confirmContact already guard on confirm_state = 'unconfirmed'. Allowing declined from confirmed means a later opt-out/unsubscribe caller could accidentally revoke an existing opt-in. Add the same state guard, return whether a transition happened, and add a test that declineContact is a no-op on a confirmed row.

Suggested change
export async function declineContact(db: D1Database, recipientHashValue: string): Promise<void> {
await db
.prepare(
`UPDATE notification_contacts
SET confirm_state = 'declined', confirm_token_hash = NULL
WHERE recipient_hash = ?`,
)
/**
* Decline an unconfirmed contact, clearing any outstanding confirm token.
* Returns whether the contact was transitioned from `unconfirmed`.
*/
export async function declineContact(
db: D1Database,
recipientHashValue: string,
): Promise<boolean> {
const result = await db
.prepare(
`UPDATE notification_contacts
SET confirm_state = 'declined', confirm_token_hash = NULL
WHERE recipient_hash = ? AND confirm_state = 'unconfirmed'`,
)
.bind(recipientHashValue)
.run();
return result.meta.changes > 0;
}

Comment on lines +142 to +156
export async function recordConfirmSent(
db: D1Database,
recipientHashValue: string,
tokenHash: string,
epochMs: number,
): Promise<boolean> {
const result = await db
.prepare(
`UPDATE notification_contacts
SET confirm_token_hash = ?, last_confirm_sent_at_epoch_ms = ?
WHERE recipient_hash = ? AND confirm_state = 'unconfirmed'`,
)
.bind(tokenHash, epochMs, recipientHashValue)
.run();
return result.meta.changes > 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] suppress rejects non-finite epochMs, but recordConfirmSent accepts any number. Storing NaN or Infinity as last_confirm_sent_at_epoch_ms breaks the numeric rate-gate comparison in canSendConfirm and contradicts the finite assumption. Add the same guard for consistency.

Suggested change
export async function recordConfirmSent(
db: D1Database,
recipientHashValue: string,
tokenHash: string,
epochMs: number,
): Promise<boolean> {
const result = await db
.prepare(
`UPDATE notification_contacts
SET confirm_token_hash = ?, last_confirm_sent_at_epoch_ms = ?
WHERE recipient_hash = ? AND confirm_state = 'unconfirmed'`,
)
.bind(tokenHash, epochMs, recipientHashValue)
.run();
return result.meta.changes > 0;
export async function recordConfirmSent(
db: D1Database,
recipientHashValue: string,
tokenHash: string,
epochMs: number,
): Promise<boolean> {
if (!Number.isFinite(epochMs)) {
throw new TypeError("recordConfirmSent requires a finite epochMs");
}
const result = await db
.prepare(
`UPDATE notification_contacts
SET confirm_token_hash = ?, last_confirm_sent_at_epoch_ms = ?
WHERE recipient_hash = ? AND confirm_state = 'unconfirmed'`,
)
.bind(tokenHash, epochMs, recipientHashValue)
.run();
return result.meta.changes > 0;
}

.prepare(`SELECT confirm_token_hash FROM notification_contacts WHERE recipient_hash = ?`)
.bind(recipientHashValue)
.first<{ confirm_token_hash: string | null }>();
if (!row || row.confirm_token_hash === null) return false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The short-circuit return false paths before constantTimeEqual mean the function's elapsed time reveals (a) whether the recipient hash exists and (b) whether it has an active confirmation token, partially defeating the constant-time goal. Since recipient_hash is exposed in confirmation links, consider restructuring so the comparison always runs against a dummy fixed-length value when no real token is present, or document that only the XOR comparison itself — not the whole function — is intended to be constant-time.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 16, 2026
@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-rereview Author pushed changes since the last review labels Jul 16, 2026
- declineContact gates on confirm_state = 'unconfirmed' and returns a
  boolean, so an unsubscribe-path bug can't revoke a confirmed opt-in.
- recordConfirmSent rejects a non-finite epochMs (mirrors suppress) to
  keep NaN out of the rate-gate comparison.
- confirmContact always runs the constant-time token compare, against a
  fixed-length dummy when the row or token is absent, so elapsed time
  never reveals recipient existence or a pending token.
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jul 16, 2026
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jul 16, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of W10.4 slice B (contact-state foundation).

Approach judgment: The slice is the right shape for the W10.4 workstream. Keeping contact state hash-keyed, putting plaintext only on the future delivery row, and using raw D1 with parameter binding fits the labeler’s architecture and matches the existing signing-runtime.ts secret-binding pattern. The HMAC-SHA256 recipient hashing is deterministic, pepper-sensitive, and uses the globalThis key-cache idiom consistently.

What I checked

  • Migration 0007 schema: recipient_hash primary keys, no plaintext columns, CHECK constraints on confirm_state and suppression reason.
  • src/notification-contacts.ts: hashing, HMAC key cache, recordConfirmSent/confirmContact/declineContact state guards, token single-use, suppress idempotency, and D1 parameter binding.
  • wrangler.jsonc / worker-configuration.d.ts secret wiring and vitest.config.ts test binding.
  • Test coverage for the state machine, suppression earliest-reason retention, rate-gate boundaries, and the no-plaintext schema assertion.

Headline conclusion: The prior-review gap is fixed — declineContact now guards on confirm_state = 'unconfirmed' so it cannot revoke a confirmed opt-in, and that path is tested. The remaining issue is a test-only convention note (SQL identifier interpolation). I did not run pnpm typecheck/lint/test because there is no shell in this environment; those passing is unverified from this review.

No changeset is required because @emdash-cms/labeler is private, and there are no user-facing admin UI strings in this slice.

async function columnNames(
table: "notification_contacts" | "notification_suppressions",
): Promise<string[]> {
const result = await db().prepare(`PRAGMA table_info(${table})`).all<{ name: string }>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The columnNames helper interpolates a table name into a PRAGMA table_info(...) string. The table argument is a typed union literal in this file, so there is no runtime injection risk today, but it still violates the repo convention of never interpolating into SQL. Since SQLite PRAGMA table_info cannot parameterize the table name, validate the identifier explicitly.

Suggested change
const result = await db().prepare(`PRAGMA table_info(${table})`).all<{ name: string }>();
async function columnNames(
table: "notification_contacts" | "notification_suppressions",
): Promise<string[]> {
if (!/^[a-z_]+$/.test(table)) {
throw new Error(`invalid table name: ${table}`);
}
const result = await db().prepare(`PRAGMA table_info(${table})`).all<{ name: string }>();
return result.results.map((row) => row.name);
}

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 16, 2026
@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-rereview Author pushed changes since the last review labels Jul 16, 2026
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jul 16, 2026
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jul 16, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approach judgment: This is the right slice for W10.4. Keeping contact state hash-keyed, leaving plaintext to the future delivery row, and using raw D1 with parameter binding fits the labeler’s architecture and mirrors the existing signing-runtime.ts secret-binding pattern. The HMAC-SHA256 hashing is deterministic, pepper-sensitive, and uses the globalThis key-cache idiom consistently.

What I checked (re-review)

  • Migration 0007 schema: recipient_hash PKs, no plaintext columns, CHECK constraints on confirm_state and suppression reason.
  • src/notification-contacts.ts: hashing, HMAC key cache, recordConfirmSent/confirmContact/declineContact state guards, token single-use, suppress idempotency, and D1 parameter binding.
  • wrangler.jsonc / worker-configuration.d.ts secret wiring and vitest.config.ts test binding.
  • Restated prior-review gap: declineContact still guards on confirm_state = 'unconfirmed', and that path is tested, so the prior fix is still in place.

Headline conclusion: The code is sound for the slice. The only remaining note is a test-only convention suggestion: the PRAGMA table_info helper interpolates the table identifier after validation, which is safe but could be tightened to an allowlist. No blocking issues.

No changeset is required because @emdash-cms/labeler is private, and there are no user-facing admin UI strings in this slice. I did not run pnpm typecheck/lint/test because there is no shell in this environment; those passing is unverified from this review.

// PRAGMA cannot parameterize the table name; validate the identifier before
// interpolating.
if (!/^[a-z][a-z0-9_]*$/.test(table)) throw new TypeError(`invalid table identifier: ${table}`);
const result = await db().prepare(`PRAGMA table_info(${table})`).all<{ name: string }>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The PRAGMA table_info(${table}) call validates the identifier before interpolating, so it is not an injection bug. To make the test fully robust and avoid raw identifier interpolation altogether, consider an explicit allowlist of the two table names instead of a regex. This also makes it obvious at a glance that only these tables are ever queried here.

Suggested change
const result = await db().prepare(`PRAGMA table_info(${table})`).all<{ name: string }>();
const allowed: Record<typeof table, string> = {
notification_contacts: "notification_contacts",
notification_suppressions: "notification_suppressions",
};
if (!(table in allowed)) throw new TypeError(`invalid table identifier: ${table}`);
const result = await db().prepare(`PRAGMA table_info(${allowed[table]})`).all<{ name: string }>();

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 16, 2026
@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-rereview Author pushed changes since the last review labels Jul 16, 2026
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jul 16, 2026
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jul 16, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of W10.4 slice B — contact-state foundation for publisher notifications.

Approach judgment: This remains the right slice. Keeping contact state hash-keyed, leaving plaintext to the future W10.5 delivery row, and using raw D1 prepared statements with parameter binding fits the labeler’s architecture and mirrors the existing signing-runtime.ts secret-binding pattern. The HMAC-SHA256 hashing is deterministic, pepper-sensitive, and uses the globalThis key-cache idiom consistently.

What I checked

  • Migration 0007_notification_contacts.sql: recipient_hash PKs, no plaintext columns, CHECK constraints on confirm_state and suppression reason, forward-only SQL.
  • src/notification-contacts.ts: HMAC key cache, state guards in recordConfirmSent/confirmContact/declineContact, single-use token, constant-time compare, conditional UPDATE closing the read-then-write race, and D1 parameter binding throughout.
  • wrangler.jsonc / worker-configuration.d.ts: NOTIFICATION_HASH_PEPPER added as a required secret and typed binding.
  • vitest.config.ts: test binding stub for the new secret.
  • test/notification-contacts.test.ts: full state-machine coverage, wrong-token rejection, token single-use, suppression idempotency/earliest-reason retention, rate-gate boundaries, schema assertion, and finite-epoch validation.

Prior review follow-up: My previous test-only convention note about the PRAGMA table_info helper has been addressed — the helper now validates the table identifier against an explicit allowlist before interpolating it.

Headline conclusion: Clean for the slice. No blocking issues remain. Tooling passes (pnpm typecheck/lint/test) are unverified from this static review.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 16, 2026
@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview Author pushed changes since the last review labels Jul 16, 2026
@ascorbic
ascorbic merged commit 467b4d4 into feat/plugin-registry-labelling-service Jul 16, 2026
11 checks passed
@ascorbic
ascorbic deleted the feat/labeler-notification-contacts branch July 16, 2026 08:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla: signed review/approved Approved; no new commits since size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant