WIP: dev -> main release gate — config/env that must be decided and set before deploy #56

Draft
laoc wants to merge 324 commits from dev into main
Owner

Do not merge this yet. It is deliberately a draft. Its job right now is to be the place where the "what do we have to switch on before this reaches edufeed.org" list lives, so none of it is rediscovered on deploy day.

dev is 97 commits / 169 files ahead of main (14428 +, 2139 -). main is at faf6d14c, dev at 09842a48.

Two things that are easy to get wrong

1. Merging this does not deploy anything. .forgejo/workflows/docker-build.yml builds, smoke-tests and pushes an image — :latest for main, :dev for dev. There is no deploy step. Rolling it needs ansible-playbook playbooks/deploy_edufeed_app.yml in the homelab repo.

2. Almost every new feature is off by default. The toggles in src/routes/api/config/+server.js are parseBool(env.X, false). Nothing here breaks loudly if you forget it — the feature is simply absent, which is much harder to notice.

The gap: 25 env vars are set on dev.edufeed.org and not on edufeed.org

From playbooks/deploy_edufeed_app.yml — prod play (lines 27–367) has 76 env keys, the dev instance (368–759) has 101. Everything below is present on the dev instance and missing on prod.

Decisions someone has to make (not just "copy the value across")

  • GOOGLE_LOGIN_ENABLED — yes or no on production? Default false, and it is not set on any instance, so Google login is currently off everywhere including dev. Login via the Pomegranate/promenade FROST threshold signer; POMEGRANATE_CENTRAL_URL defaults to https://auth.njump.me and POMEGRANATE_OPERATOR_URLS to five public operators, so enabling it without setting those means depending on third-party infrastructure for authentication. That is the decision, not the flag.
  • NPUB_LOGIN_ENABLED — read-only "browse as" login. Also default false and set nowhere. Cheap and low-risk, but it is a product choice.
  • MEMBERSHIP_ENABLED — the @edufeed.org handle application flow. Default false. Currently set only on the dev instance, so the whole membership feature is absent from production today.

Membership / NIP-05 — all four are needed together

Enabling MEMBERSHIP_ENABLED without these gives a form that renders and cannot work.

  • MEMBERSHIP_ADMIN_PUBKEYS — comma-separated. This is the multi-admin setting. The dev instance sets exactly one pubkey (d2689e2f…), so the fan-out currently has a single recipient and multi-admin is off in practice. Add the second admin here, not just locally. It also gates /api/nip05 (src/routes/api/nip05/+server.js:94-100) — a pubkey in this list can provision real handles, so treat it as an authorisation list, not a notification list.
  • MEMBERSHIP_FORM_ADDRESS — the kind 30168 template 30168:<pubkey>:<d-tag>.
  • NIP05_HANDLE_DOMAIN — the domain shown to applicants and queried for .well-known/nostr.json.
  • NIP05_SERVICE_URL + NIP05_SERVICE_API_KEY — the standalone nip-05-service and its Bearer token. Without them /api/nip05 returns 503 and approvals fail at the last step, after the admin has already clicked.

Vocabularies — 11 SCHEME_NADDR_* missing on prod

Prod has 14 of the 25 the dev instance sets. Missing:

SCHEME_NADDR_KONFI_BETEILIGTE      SCHEME_NADDR_KONFI_MATERIALAUFWAND
SCHEME_NADDR_KONFI_DIMENSIONEN     SCHEME_NADDR_KONFI_METHODE
SCHEME_NADDR_KONFI_LERNFORMAT      SCHEME_NADDR_KONFI_TECHNIKBEDARF
SCHEME_NADDR_KONFI_LERNORTE        SCHEME_NADDR_KONFI_THEMEN
SCHEME_NADDR_KONFI_ZEITSTRUKTUR    SCHEME_NADDR_LANDESKIRCHEN
SCHEME_NADDR_KONFI_ZIELGRUPPEN
  • Worth a look before deploy: prod already sets RESOURCE_FORM_VARIANTS=amb,ekw (line 352), so the EKW variant is already offered there while these vocabularies are empty. I have not checked whether the EKW form on prod actually needs the Konfi ones — someone who knows the EKW form should confirm rather than assume either way.

The new NIP-101 template-driven forms are inert without these

  • RESOURCE_FORM_TEMPLATE_NADDR_AMB
  • RESOURCE_FORM_TEMPLATE_NADDR_EKW

These are the only two env vars this branch newly reads that main does not (config/+server.js:353-354). They are in no instance's env and not in .env.example. Per the comment at the read site: when set, the variant renders via the generic template-driven form; when unset it falls back to the hardcoded wizard. So the large forms rework in this branch — NIP-101 wire format, sections, displayIf, option routing, the field-type/emitter registries — does not take effect anywhere until these point at published kind-30168 templates. No error, just the old wizard.

Other services (enabled purely by presence of a URL)

  • OER_PROXY_URLenabled: Boolean(env.OER_PROXY_URL).
  • METADATA_CLEANER_URL (+ METADATA_CLEANER_MAX_UPLOAD_MB) — same pattern.
  • CALENDAR_FEATURED_AUTHORS — cosmetic; empty means no featured authors.
  • CLIENT_NAME — falls back to APP_NAME, then Edufeed.
  • BODY_SIZE_LIMIT — not read by app source; consumed by @sveltejs/adapter-node at runtime. Relevant if prod accepts uploads.

One to not copy across

  • CONCORD_ENABLED / CONCORD_RELAYS — set on the dev instance, but a case-insensitive search for concord across the whole repo at 09842a48 returns nothing. Dead config as far as this branch is concerned. Confirm before carrying it to prod.

Known open problems that are not release blockers

  • #55pnpm test on dev is not a trustworthy signal (random teardown error with all tests passing, plus two tests that need .env exported). Worth knowing before anyone reads CI on this PR as meaningful.

Verification state of the branch tip

At 09842a48, with .env exported: 5149 tests passed / 5149, 468 files, pnpm lint clean, pnpm check 0 errors (4 pre-existing warnings in test fixtures).

The membership chain specifically was browser-verified end to end on dev: encrypted per-admin fan-out with one p-tag per copy, concurrent publish, newest-application-per-applicant in the admin queue, and the partial-delivery warning. The forms rework from #50 was verified on its own branch.

Scope note

The env audit above is derived from reading /home/laoc/coding/homelab/playbooks/deploy_edufeed_app.yml and the read sites in src/routes/api/config/+server.js. I did not query the running hosts. If a container's actual environment differs from the playbook, the list is wrong and it is worth docker exec … env on edufeed-app before relying on it.

Requested by @laoc_buzz in the membership thread in #edufeed-app.

**Do not merge this yet.** It is deliberately a draft. Its job right now is to be the place where the "what do we have to switch on before this reaches edufeed.org" list lives, so none of it is rediscovered on deploy day. `dev` is **97 commits / 169 files** ahead of `main` (`14428 +`, `2139 -`). `main` is at `faf6d14c`, `dev` at `09842a48`. ## Two things that are easy to get wrong **1. Merging this does not deploy anything.** `.forgejo/workflows/docker-build.yml` builds, smoke-tests and pushes an image — `:latest` for `main`, `:dev` for `dev`. There is no deploy step. Rolling it needs `ansible-playbook playbooks/deploy_edufeed_app.yml` in the homelab repo. **2. Almost every new feature is off by default.** The toggles in `src/routes/api/config/+server.js` are `parseBool(env.X, false)`. Nothing here breaks loudly if you forget it — the feature is simply absent, which is much harder to notice. ## The gap: 25 env vars are set on dev.edufeed.org and not on edufeed.org From `playbooks/deploy_edufeed_app.yml` — prod play (lines 27–367) has 76 env keys, the dev instance (368–759) has 101. Everything below is present on the dev instance and missing on prod. ### Decisions someone has to make (not just "copy the value across") - [ ] **`GOOGLE_LOGIN_ENABLED`** — yes or no on production? Default `false`, and it is **not set on any instance**, so Google login is currently off everywhere including dev. Login via the Pomegranate/promenade FROST threshold signer; `POMEGRANATE_CENTRAL_URL` defaults to `https://auth.njump.me` and `POMEGRANATE_OPERATOR_URLS` to five public operators, so enabling it without setting those means depending on third-party infrastructure for authentication. That is the decision, not the flag. - [ ] **`NPUB_LOGIN_ENABLED`** — read-only "browse as" login. Also default `false` and set nowhere. Cheap and low-risk, but it is a product choice. - [ ] **`MEMBERSHIP_ENABLED`** — the `@edufeed.org` handle application flow. Default `false`. **Currently set only on the dev instance**, so the whole membership feature is absent from production today. ### Membership / NIP-05 — all four are needed together Enabling `MEMBERSHIP_ENABLED` without these gives a form that renders and cannot work. - [ ] `MEMBERSHIP_ADMIN_PUBKEYS` — comma-separated. **This is the multi-admin setting.** The dev instance sets exactly one pubkey (`d2689e2f…`), so the fan-out currently has a single recipient and multi-admin is off in practice. Add the second admin here, not just locally. It also gates `/api/nip05` (`src/routes/api/nip05/+server.js:94-100`) — a pubkey in this list can provision real handles, so treat it as an authorisation list, not a notification list. - [ ] `MEMBERSHIP_FORM_ADDRESS` — the kind 30168 template `30168:<pubkey>:<d-tag>`. - [ ] `NIP05_HANDLE_DOMAIN` — the domain shown to applicants and queried for `.well-known/nostr.json`. - [ ] `NIP05_SERVICE_URL` + `NIP05_SERVICE_API_KEY` — the standalone nip-05-service and its Bearer token. Without them `/api/nip05` returns **503** and approvals fail at the last step, after the admin has already clicked. ### Vocabularies — 11 `SCHEME_NADDR_*` missing on prod Prod has 14 of the 25 the dev instance sets. Missing: ``` SCHEME_NADDR_KONFI_BETEILIGTE SCHEME_NADDR_KONFI_MATERIALAUFWAND SCHEME_NADDR_KONFI_DIMENSIONEN SCHEME_NADDR_KONFI_METHODE SCHEME_NADDR_KONFI_LERNFORMAT SCHEME_NADDR_KONFI_TECHNIKBEDARF SCHEME_NADDR_KONFI_LERNORTE SCHEME_NADDR_KONFI_THEMEN SCHEME_NADDR_KONFI_ZEITSTRUKTUR SCHEME_NADDR_LANDESKIRCHEN SCHEME_NADDR_KONFI_ZIELGRUPPEN ``` - [ ] Worth a look before deploy: prod **already** sets `RESOURCE_FORM_VARIANTS=amb,ekw` (line 352), so the EKW variant is already offered there while these vocabularies are empty. I have not checked whether the EKW form on prod actually needs the Konfi ones — someone who knows the EKW form should confirm rather than assume either way. ### The new NIP-101 template-driven forms are inert without these - [ ] `RESOURCE_FORM_TEMPLATE_NADDR_AMB` - [ ] `RESOURCE_FORM_TEMPLATE_NADDR_EKW` These are the **only two env vars this branch newly reads** that `main` does not (`config/+server.js:353-354`). They are in **no** instance's env and not in `.env.example`. Per the comment at the read site: when set, the variant renders via the generic template-driven form; when unset it falls back to the hardcoded wizard. So the large forms rework in this branch — NIP-101 wire format, sections, `displayIf`, option routing, the field-type/emitter registries — **does not take effect anywhere until these point at published kind-30168 templates.** No error, just the old wizard. ### Other services (enabled purely by presence of a URL) - [ ] `OER_PROXY_URL` — `enabled: Boolean(env.OER_PROXY_URL)`. - [ ] `METADATA_CLEANER_URL` (+ `METADATA_CLEANER_MAX_UPLOAD_MB`) — same pattern. - [ ] `CALENDAR_FEATURED_AUTHORS` — cosmetic; empty means no featured authors. - [ ] `CLIENT_NAME` — falls back to `APP_NAME`, then `Edufeed`. - [ ] `BODY_SIZE_LIMIT` — not read by app source; consumed by `@sveltejs/adapter-node` at runtime. Relevant if prod accepts uploads. ### One to *not* copy across - [ ] `CONCORD_ENABLED` / `CONCORD_RELAYS` — set on the dev instance, but a case-insensitive search for `concord` across the whole repo at `09842a48` returns **nothing**. Dead config as far as this branch is concerned. Confirm before carrying it to prod. ## Known open problems that are not release blockers - [ ] #55 — `pnpm test` on `dev` is not a trustworthy signal (random teardown error with all tests passing, plus two tests that need `.env` exported). Worth knowing before anyone reads CI on this PR as meaningful. ## Verification state of the branch tip At `09842a48`, with `.env` exported: **5149 tests passed / 5149**, 468 files, `pnpm lint` clean, `pnpm check` **0 errors** (4 pre-existing warnings in test fixtures). The membership chain specifically was browser-verified end to end on `dev`: encrypted per-admin fan-out with one p-tag per copy, concurrent publish, newest-application-per-applicant in the admin queue, and the partial-delivery warning. The forms rework from #50 was verified on its own branch. ## Scope note The env audit above is derived from reading `/home/laoc/coding/homelab/playbooks/deploy_edufeed_app.yml` and the read sites in `src/routes/api/config/+server.js`. **I did not query the running hosts.** If a container's actual environment differs from the playbook, the list is wrong and it is worth `docker exec … env` on `edufeed-app` before relying on it. Requested by @laoc_buzz in the membership thread in #edufeed-app.
laoc added 97 commits 2026-07-30 06:16:00 +00:00
MEMBERSHIP_ADMIN_PUBKEYS already granted every listed admin access to
/admin/membership and the /api/nip05 proxy, but the application itself
was NIP-44-encrypted to adminPubkeys[0] only — extra admins saw
ciphertext they could not decrypt. NIP-44 is pairwise, so on submit the
form now publishes one encrypted kind 1069 copy per configured admin,
each p-tagged with its own recipient.

The approvals panel loads and lists only the copies addressed to the
logged-in admin (the others are undecryptable there), and the applicant
prefill decrypts a previous response against the admin in its own p-tag
instead of assuming adminPubkeys[0].

The kind 30168 template author is now derived from
MEMBERSHIP_FORM_ADDRESS rather than adminPubkeys[0], so reordering the
admin list no longer breaks template loading.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address three findings from review of the multi-admin fan-out:

- Mirror the signed copies into the local event store only after every
  copy has been published. A mid-loop add flipped the "existing
  response" state — showing the already-applied banner (and potentially
  prompting the signer for decryption) while the submit spinner was
  still running.
- Build the a/p tags of each copy with relay hints via buildATagWithHint
  / buildPTagsWithHints, matching the project convention (CLAUDE.md) and
  the neighboring SendFormModal.
- Mirror manager.active$ into local state in the approvals panel so an
  account switch re-subscribes the response loader with the new admin's
  pubkey instead of keeping the previous admin's filter.

Also rejoin colon-containing d-tags when deriving the form identifier
from MEMBERSHIP_FORM_ADDRESS, matching formCoordinateToNaddr.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two surfaces missed by the fan-out commit still assumed the first
configured admin:

- MembershipCard decrypted the user's own application against
  adminPubkeys[0]; a fan-out copy addressed to another admin failed to
  decrypt and hid the add-to-profile CTA. Decrypt against the admin in
  the response's own p-tag, as the application form already does.
- useMembershipPendingCount loaded responses with adminPubkeys[0] and
  counted every a-tag match, so other admins saw the wrong queue and
  each application counted once per admin. Load and count only the
  copies p-tagged to the active admin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The VN approval DM is easy to miss, so the Termi assistant's nip05 hint
now tracks the membership application instead of only nagging to apply:

- 'apply' — no application yet; the existing request-address reminder.
- 'pending' — application submitted but not yet in the upstream
  directory; a passive "waiting for review" note without a button.
- 'ready' — the wished handle resolves to the user upstream; a
  celebratory card whose Activate button publishes the nip05 profile
  update right from the chat. When a different nip05 is already on the
  profile, the button routes to the settings card's replace-or-add
  choice instead of silently replacing.

The ready card has its own dismiss flag so hiding the early reminder
does not swallow the later grant notice. Detection (own application →
decrypt wished handle against its p-tag admin → .well-known check,
re-run on window focus) is extracted from MembershipCard into the
shared useMembershipGrantState hook; the card now consumes it too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two fixes to the handle-application flow.

Encryption: MembershipApplicationForm guarded on `signer.nip44Encrypt`,
but applesauce signers expose NIP-44 only as `signer.nip44.encrypt` (see
helpers/nip44.js). The guard was never true, so every application fell
through to the plaintext branch — all 13 applications on
relay.edufeed.org carry name, affiliation and motivation in readable
`response` tags. Submission now goes through hasNip44() +
signer.nip44.encrypt, and a signer without NIP-44 gets an error instead
of a plaintext fallback: these answers must never be published in the
clear.

UX: the apply CTA in the Termi assistant used to navigate to /settings,
where the user had to find the membership card and click a second time,
with no sign that anything had happened. The form now lives in
MembershipApplyModal, opened from both the Termi hint and the settings
card. Submitting inside the modal mirrors the published copies into the
event store before closing, so the surface behind it flips to "waiting
for review" in place — no reload, and no reason to apply twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Logging in via private key, npub or a signing app left the user logged
in but still staring at the login dialog: each sub-flow called
onAccountCreated() on success and ModalManager reacted by transitioning
*back* to the login modal instead of closing. The sub-modals did close
their own dialog afterwards, but the manager had already swapped
components, so the close landed on the wrong dialog. Only the
browser-extension path (handled inline in LoginModal) closed correctly.

The three onAccountCreated handlers now close the whole stack. To keep
the "already logged in / already added" notice from being flashed away,
the sub-modals close their own dialog and only then signal the parent,
since that callback unmounts them — so the notice still gets its 1.2s
before the flow ends. LoginWithBunker additionally never closed its
dialog at all on the new-account path; it does now.

The e2e suite encoded the bug rather than catching it: the shared login
fixture pressed Escape up to three times to get past the re-opened
modal, and npub-login asserted the login modal became visible again.
Both now assert that no dialog remains open.

Full suite at this base: 1 failure (ResourceFormWizard.edit-prefill),
which also fails on dev; dev additionally shows 3 parallel-load flakes.
Lint and svelte-check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge branch 'fix/login-modal-closes-after-login' into dev
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m39s
00ad97bb2f
An approval is answered with a NIP-17 gift wrap, which resolves the
*recipient's* kind 10050. Users who signed up through edufeed get a
default 10050 at signup (SignupModal), but everyone who arrived with an
existing key — extension, bunker, nsec — never passes through it. Their
"your address is ready" DM fell through to the generic fallback relays,
which is the same "the user never sees it" problem the Termi hint was
built to solve.

Applying is the right moment to fix that: the applicant is present and
signing anyway. ensureApplicantRelayLists settles both lists first,
orchestrating the services that already own this rather than repeating
them — fetchRelayListResolution + publishDefaultRelayList for the kind
10002, and the DM service's own settle-aware self-check +
ensureDmRelayList for the kind 10050. On the sending side the approval
panel now prefetches the applicant's DM relays via ensureRecipientDmRelays,
the same call ConversationThread makes before sending a wrap, so
applications that predate this still reach a real inbox.

Both backfills publish a replaceable event, so each is gated on *proof*
of absence rather than an empty answer: fetchRelayList resolved null both
for a confirmed miss and for a lookup that merely timed out, and
defaulting on the latter would supersede a relay list the applicant does
have. fetchRelayList's body moves into fetchRelayListResolution, which
reports 'found' | 'absent' | 'unknown'; fetchRelayList itself is now a
wrapper with an unchanged contract for its routing callers.

Routing, separately: the application was published through the generic
outbox publisher, which unions in the author's write relays — the public
fallback set for anyone without a kind 10002. That is how 13 applications
carrying real names ended up on nos.lol. publishApplicationCopy sends
each copy to the app-managed communikey relays only, which is where both
the admin panel and the applicant already read them from, and the form
now reports a failure instead of claiming an application was submitted
that no relay accepted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Extract build/parse into src/lib/helpers/forms/format.js: settings tag
replaces discrete description/public/confirmation_message/auto_response
tags, field tags gain a positional options array + renderElement-tagged
settings object, and choice fields encode [id,label] (or triples with a
nextSection config) instead of bare label strings. forms.js delegates the
moved exports so no import sites change. Events without a settings tag
still parse via a legacy path so old-dialect and foreign/garbage kind-30168
events never throw.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FieldsRenderer now renders option labels but emits optionIds; multi-select
joins with ";" instead of ",". FormBuilder/FormBuilderFieldRow selectOptions
is FormFieldOption[] ({id,label}); the manual options editor generates ids
via generateOptionId. FormResponses maps stored optionIds back to labels for
display, passing through unknown ids for legacy label-valued responses.
Add forms/crypto.js adapters (nip44EncryptWith/nip44DecryptWith/signerHasNip44)
that try both the nested signer.nip44.{encrypt,decrypt} and flat
signer.nip44Encrypt/-Decrypt surfaces, and route every forms/membership call
site through them instead of ad-hoc raw checks. buildResponseTags now emits
the 4-element NIP-101 response tag shape (['response', id, value, '{}']).

The respond page now reads isPublic from parseFormTemplate(formEvent) instead
of the raw ['public'] tag, which is wrong for new-format (settings-JSON)
templates. MembershipApplicationForm's submit path used to silently fall back
to plaintext for signers that only exposed the nested nip44.encrypt surface
(e.g. NIP-07 extensions) because it only checked the flat nip44Encrypt method
— now it always attempts encryption and surfaces failures via the existing
error handling instead of leaking PII in plaintext.

Also migrated MembershipCard, MembershipApprovalsPanel, and SendFormModal,
which had the same raw nip44 surface checks, so crypto.js is the only path
left in forms/membership code (verified via grep).
Review follow-ups:
- MembershipApplicationForm test: signer without any NIP-44 surface must
  surface a visible error and publish nothing (no plaintext response tags).
  Verified the test fails against the old plaintext-fallback code.
- respond page: read confirmationMessage from parseFormTemplate instead of
  the raw confirmation_message tag (dropped on new-format templates).
- forms list page: same class of bug — raw description read and public-tag
  check migrated to parseFormTemplate (name/fieldCount along with them).
Foreign/malformed 30168 events can repeat option, section, or field ids;
those values feed keyed {#each} blocks in FieldsRenderer/FormRenderer and
a duplicate key crashes the whole page (each_key_duplicate). Dedupe by id
(keep first occurrence) in parseFormTemplate (NIP-101 and legacy paths)
and add defense-in-depth deduping to orderedSections for hand-built
templates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FormBuilder only carried required/placeholder/min/max/options/multiple out
of a loaded field's options, and never passed sections into
buildFormTemplateTags. Re-saving a script-published sectioned/branching
template silently wiped section grouping and per-field displayIf. There is
no branching-authoring UI yet, so this is a preserve-and-re-emit
round-trip fix: displayIf now rides through FieldState.displayIf (both the
edit-load and fork mappings) and back into the field's options on publish;
the loaded template's sections are captured into templateSections and
passed to buildFormTemplateTags when non-empty.

parseFormTemplate already carried displayIf through unmodified (it spreads
unknown fieldSettingsJSON keys), so no format.js change was needed there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RelationFieldAdapter owns the selected-relation list as field value since
AMBResourceSearchInput is add-only; adapter renders no label/error itself
(FieldsRenderer already does), matching the Task-2 CreatorFieldAdapter
correction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The outbound sync $effect fired onchange([]) on mount of an empty relation
field: FormRenderer seeds non-vocab fields with the string default '', and
`refs=[] !== value=''` is always true, so it mutated values[field] ''→[] with
no user action. Delete the effect; emit only from add()/remove() directly and
keep refs as a read-only $derived for rendering (parent value flows back).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds rTagEmitter (NIP-24 r-tags) to the AMB emitter registry for the
external-urls field type, and ExternalUrlFieldAdapter wrapping
ExternalUrlInput per the registry contract (no own label/error,
readonly static list, no mount-time onchange).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ExternalUrlInput rendered `{#if label || m.external_url_label()}` so label=""
did not suppress its heading — the adapter showed two labels. Change to plain
`{#if label}` (CreatorInput convention). Only other caller (ResourceFormWizard
step 5) already passes an explicit label. Add real-component guard test that
renders the adapter through FieldsRenderer with the unmocked ExternalUrlInput.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add templateNaddr to the resource-form-variants registry (env-driven via
RESOURCE_FORM_TEMPLATE_NADDR_<VARIANT>, mirroring the schemeNaddrs pattern)
so a variant can point at a published kind-30168 form template. Extract the
create-resource route's body into TemplateResourceForm.svelte, shared by
/forms/[naddr]/create-resource and the new templateNaddr branch of
/create/resource/[variant]. Content is now derived from the form field whose
output is amb:description instead of a removed content pseudo-tag.
Extends the existing amb-basic form definition with the composite field
types landed in Tasks 2-5 (creator, amb-relation x2, external-urls, date,
plus url/checkbox/text-array/vocab-select) so the template exercises the
full slice-1 AMB field set. Wires sections through buildFormTemplate so
future forms with grouped sections serialize correctly.
Spec is named amb-basic-form.test.js (not .spec.js per the task brief) —
playwright.config.js testMatch is '**/*.test.js', so a .spec.js file would
never be picked up by pnpm run test:e2e.
Same pre-existing class of failure c640a759 fixed elsewhere: the loaders
barrel (loaders/index.js) resolves getArticleRelays/getEducationalRelays/
getCalendarRelays eagerly at module init through the shared community-loader
chain, but this suite's relay-helper mock didn't provide them, so the whole
file failed to collect any tests. Found while running the full verification
battery for the AMB-basic form-template slice (Task 7) — unrelated to that
slice's own code.

Two further suites (MembershipApplicationForm, MembershipCard) hit the same
class of gap but cascade into missing exports on additional mocked modules
(nostr-infrastructure's `pool`, applesauce-loaders' createReactionsLoader);
left unfixed as out of scope for this slice — see task report.
THIS slice regressed collection of MembershipApplicationForm.test.js and
MembershipCard.test.js — not pre-existing (corrected diagnosis). Root cause:
src/lib/config/form-field-types.js now statically imports the new adapters
(CreatorFieldAdapter → CreatorInput → profile-subscription.js → loaders/
profile.js; RelationFieldAdapter → AMBResourceSearchInput → loaders/
amb-search.js), so every FieldsRenderer/FormRenderer render eagerly evaluates
createAddressLoader/createReactionsLoader and get*Relays at module init. The
membership suites render a form, so their mocks — which didn't cover this
deeper chain — failed to collect. No runtime break: the loaders only throw
under the test-mock gap. Same test-mock-completeness class as c640a759 and
e0455525; assertions unchanged (7/7 MembershipApplicationForm still pass).

Fix: complete both suites' mocks for applesauce-loaders/loaders
(createAddressLoader/createEventLoader/createReactionsLoader), relay-helper.js
(get{Article,Educational,Calendar,Kanban,ProfileLookup,EventLoaderLookup,
Fallback}Relays), and nostr-infrastructure's pool.

Follow-up (later slice): the component field-type registry eager-static-imports
heavy adapters, so every form render pulls in profile + AMB-search machinery.
A lazy-loaded registry is the root fix.
Untouched checkboxes stored '' by FormRenderer were still emitting
isAccessibleForFree:false, giving every amb-basic resource a false
value regardless of user action. Emit no tag for undefined/null/''.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
buildAMBResourceTags can emit a ['d', url] tag itself (a url field mapped
to amb:id via dtagEmitter, e.g. the published amb-basic template), but
handleSubmit computed dTag before filtering, then discarded the emitted d
by always replacing it with crypto.randomUUID() — the user-typed
identifier landed on no tag at all. Extract resolveResourceDTag: edit mode
keeps the resource's existing d-tag for addressable stability, create mode
honors the emitted d and only falls back to a UUID when the form didn't
produce one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The field-output section said amb:<property> always emits under the bare
<property> key, but four props are special-cased by the emitter registry
(id→d, license→license:id, isAccessibleForFree→boolean with skip-when-empty,
description→also mirrors into event content). A foreign client following
only the bare-property rule would diverge. Add a table listing the four
special mappings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire Task 1's extractSections/interleaveSections/isSectionMarker helpers into
FormBuilder.svelte: a section is a type:'section' entry in the fields list,
addSection() appends one, the field render loop renders a divider (title +
optional description, reusing the same drag/move/delete controls) for
section items, publish() builds items then splits via extractSections into
formFields + settings.sections, and load/fork seed fields via
interleaveSections. Removes the read-only templateSections passthrough.
Groups the 16 amb-basic fields into 5 logical wizard steps: Grunddaten,
Einordnung, Rechte, Inhalt & Urheber, Beziehungen. Adds test to verify
round-trip parsing of sections via NIP-101 format.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drives the full builder-authoring flow (sections, option->section
routing, displayIf show-if) end-to-end through /forms/new and the
/respond fill wizard against the real relay + build. Passed on the
first run, no test.fixme needed - see COVERAGE.md for why the
publish/fill-navigation round trip is reliably observable here (local
eventStore + client-side nav), unlike the amb-basic-form.test.js
precedent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a third Section C to the form-builder authoring E2E and routes "Red"
there explicitly while linear order would visit Section B next. With only
2 sections an explicit route to "the next section" was indistinguishable
from fallthrough; skipping B proves the route (not fallthrough) drove
navigation. Keeps the displayIf show/hide assertions, now checked on both
the fallthrough path (Blue reaches C via B, condition false) and the
routed path (Red reaches C directly, condition true). Updates
e2e/COVERAGE.md to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Konfi's ext:ekw:konfi:<slug>:id was a 5-segment key ambiguously parsed
by relay (left-anchored) vs app (right-anchored), corrupting live data
for relay consumers. Amended NIP-AMB (nips@f822603) makes the grammar
normative: ns/facet MUST be colon-free. Lift konfi into a separate
ekw.konfi namespace (one EKW_KONFI_NS constant). Slice A already
conformant (ext:<form-d-tag>:<field-id>). Read parser repoints in
lockstep; no back-compat shim (Bumble re-publishes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TemplateResourceForm now writes and edit-reads kind-30142 resources through
the shared amb-nostr-converter (formValuesToAmbJson -> ambToNostr for write,
nostrToAmb -> ambJsonToFormValues for edit-prefill) via the new pure
buildTemplateResourceSubmission helper, which preserves the d-tag
reconciliation contract (edit mode never clobbers the existing d-tag) and
content fallback behavior.

getFormReferenceFromResource and resolveResourceDTag move to the new
src/lib/helpers/educational/formReference.js (also home to the shared
SelectedConcept typedef). form-to-amb.js and forms/amb-emitters.js are
retired along with their tests; the surviving getFormReferenceFromResource/
resolveResourceDTag tests move to formReference.test.js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Key holder confirmed reverse-DNS + authorized re-publishing existing
Konfi events after the new pipeline is verified. Non-konfi ekw ns stays.
Task 7 (gated, dry-run-first) migrates old ext:ekw:konfi:* events.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
buildResourceData whitelists fields; today it forwards only the pre-built
konfiTags array, not raw konfi scheme-key fields. Removing the hand-append
without forwarding raw fields would silently drop all Konfi facets from
real publishes (preview path masks it). Task 5 amended to fix the data
flow + test the real resourceData publish path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- konfiTags.js: repoint KONFI_PREFIX from ext:ekw:konfi: (illegal 5-segment
  shape) to ext:org.edufeed.ekw.konfi: (EKW_KONFI_NS). Drop the now-dead
  emitKonfiVocabTags/emitKonfiScalarTags; keep parseKonfiTags. Its allowCustom
  branch now reads the bare ext:<ns>:<facet> tag (no :custom suffix) to match
  how ambToNostr actually serializes a facet's mixed Concept[]/string[] items.
- buildResourceData.js: forward formData.bildungsbereich and, via a new
  config-driven collectKonfiRawFields() (walks BILDUNGSBEREICHE.konfi.step4SubSteps),
  the raw Konfi scheme-key fields (<schemeKey>Ids/Labels/Custom, scalar
  tagSlug values). Without this, formDataToAmbExt's Konfi branch sees
  undefined and real Konfi publishes emit zero konfi facets (Task-4 review
  finding). Drop the konfiTags param/arg.
- educational-actions.svelte.js: remove the formDataToEkwTags/konfiTags
  hand-append loops in createResource/updateResource — EKW/Konfi facets now
  come solely from amb.ext via ambToNostr. Re-derive isKonfi from
  formData.bildungsbereich (was reading the now-removed konfiTags array) and
  emit the Bildungsbereich NIP-32 L/l tag directly via a new
  getBildungsbereichTag() helper, decoupled from the deleted Konfi tag
  builder.
- resource-form-variants.js: EXTENSION_NAMESPACE_LABELS registry key moved
  from 'ekw:konfi' to EKW_KONFI_NS so the resource view page keeps showing
  curated Konfi facet labels under the new namespace.
- Delete now-dead formDataToEkwTags.js/formDataToKonfiTags.js and their
  emit-only unit tests (verified zero remaining production callers).
- Add publish-path tests (buildResourceData → convertFormDataToAMB →
  ambToNostr) proving EKW+Konfi facets and the ext:<ns>:<facet>[:sub]
  conformance grammar survive the real flow, not just the preview; rewrite
  konfiRoundTrip.test.js on the same real path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ran targeted vitest suites (413 files/4742 tests), pnpm check (0 errors),
pnpm lint (clean), and the full pnpm test suite (459 files/5004 tests) --
all green, nothing traced back to the Slice A/B converter migration; the
only "errors" are the pre-existing GlobalFAB teardown-race unhandled
rejections. Re-ran e2e/amb-basic-form.test.js: test 1 passes against the
converter-backed TemplateResourceForm; test 2 was temporarily un-fixme'd
to re-check whether the sandbox relay-read-back timeout had cleared --
it reproduced the identical documented timeout under the same
contention signature, confirming the limitation is environmental, not a
regression, so it stays test.fixme. Document the re-verification in
e2e/COVERAGE.md.
Re-publishes existing ext:ekw:konfi:* kind-30142 events into the
conformant ext:org.edufeed.ekw.konfi:* namespace. Mechanical, value-
preserving transform (incl. :custom -> bare tag, matching the converged
emitter). Dry-run over live relays: 11 events across 3 authors, 0
warnings. PUBLISH mode needs each author's signing key
(MIGRATE_NSEC_<pubkey8>); run post-deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Quality pass over 818841ad, no behaviour changes.

publishApplicationCopy carried its own copy of the per-relay fan-out that
publishEvent and publishGiftWrap already each have: same 5s timeout, same
Promise.allSettled over pool.relay(url).publish, same empty-set guard and
return shape. Lifted that into publishToRelays() in publish-service.js —
"publish to exactly these relays, no outbox union", which is the primitive
the scoped publishers actually need — and reduced publishApplicationCopy to
relay selection plus one delegated call. publishEvent and publishGiftWrap
still carry their copies; migrating them touches the app's main publish
path and belongs in its own change, not a tidy-up.

ensureApplicantRelayLists tested the lookup outcome in both arms of its
kind 10002 guard. Disqualify first, then act, so it reads in the same shape
as the kind 10050 block below it.

settle() in relay-service took outcome = 'found' as a default that no call
site ever relied on: the only caller that omits it passes a list, and that
branch hardcodes 'found'. Narrowed the parameter to the empty path, where
it is the only thing it can mean, and made an unlabelled empty settle
resolve as 'unknown' — the conservative verdict, since write-path callers
must not backfill on an unproven absence.

The submit path awaited ensureApplicantRelayLists, then buildATagWithHint,
then buildPTagsWithHints in series. They resolve relay hints for disjoint
pubkeys — applicant, form author, admins — so none reads what another
writes, and each can sit out an 8s relay-lookup timeout. Run together.

Full suite: 4978 passed, 1 failed (ResourceFormWizard.edit-prefill, which
fails on dev too). Lint and svelte-check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three defects, one shape: each gift-wrap send site had to remember the
same prerequisites, and the sites that forgot were silently wrong.

publishGiftWrap never checked response.ok. relay.publish RESOLVES with
{ok:false, message} when a relay *rejects* an event and only throws on
connection or timeout errors, so a rejected gift wrap was counted as a
delivered DM. publish-service and the membership publisher both check it;
this copy did not. It now delegates to publishToRelays(), which already
owns that check — so the fan-out lives in one place instead of two.

SendWrappedMessage resolves participant relays from the EventStore only
and never hits the network, so a recipient whose kind 10050 has not been
loaded gets their wrap sprayed at the public fallback relays. Only
ConversationThread and the membership approvals panel prefetched it;
InviteToEventModal and ReportMetadataModal called ensureDmRelayList()
(their own inbox) and stopped there. Calendar invites and metadata
feedback were routed to relays the recipient never chose.

sendWrappedDm() now owns both prerequisites — ensureDmRelayList() for the
sender so a reply has somewhere to land, ensureRecipientDmRelays() for the
recipients so the wrap can be routed — and all four send sites go through
it. The prep is best-effort and runs in parallel: a dead lookup relay must
not swallow the message. The send itself is not, so callers still surface
a rejection to the user.

ConversationThread is migrated too, though it was already correct: leaving
it out would have left the wrapper standing next to a hand-rolled copy of
what it exists to own. It passes the recipient list for the relay lookup
and the full participant list to the action, since a group wrap is
addressed to everyone including the sender.

Verification at this tree: full suite 4990 passed / 0 failed (447 files),
lint and svelte-check clean (0 errors). 12 new tests. The suite still
exits 1 on unhandled EnvironmentTeardownError rejections from
GlobalFAB.test.js under parallel load — dev at 00ad97bb produces 5 of
the same with every test passing, so it is pre-existing and not from
this change.

Not fixed here, and worth its own change: ensureDmRelayList() reads the
EventStore only, so it cannot tell "no kind 10050" from "not fetched
yet" — the same trap 818841ad fixed on the membership path by gating on
getDmRelayCheckStatus() === 'absent'. Every DM send site has always had
this; gating it here would skip the backfill whenever the check has not
settled, which needs its own decision rather than a quiet change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
parseTagKey still implemented the right-anchored heuristic ("last segment =
facet, prior segments joined by ':' = ns") that the amended NIP-AMB replaced.
On conformant data it agrees with left-anchored parsing, so nothing broke --
but it *guessed* at surplus-segment keys, so edufeed-app kept happily reading
un-migrated ext:ekw:konfi:* events that the relay and every other conformant
consumer must drop. Landed on this branch rather than a second worktree
because the branch already owns these files.

- parseExtensionTags.js: replace parseTagKey with left-anchored fixed arity,
  ported from amb-nostr-converter@ff26856 src/converters/nostrToAmb.ts --
  split on ':', offset 1 for 'ext:' / 0 for legacy 'ekw:', ns = segments[off],
  facet = segments[off+1], sub = the joined remainder or null, then validate
  sub against the closed set {id, type, name, prefLabel:<lang>} and return
  null otherwise. Delete the doc comment describing the old heuristic; it
  documented the bug. Rewrite the stale module header, which still claimed
  "<ns> may itself contain colons" and named the 30168:<pub>:<d> coordinate
  as the form namespace.
- Give sub === 'name' a real branch. It is in the NIP's closed sub set, so it
  now passes validation and would otherwise be parsed, accepted, and silently
  dropped -- the same failure class NIP-BOSS found in the converter's
  reconstructExt. Currently unreachable: 0 ext:*:*:name tags across 8476
  kind-30142 events scanned 2026-07-29.
- extensionMetadata.js: isFormDriven compared ns against the full
  30168:<pub>:<d> coordinate, which is not a legal <ns> and no longer survives
  parsing. Compare against the form's bare d-tag and drop formCoordToNs.
- Tests: invert the form-coordinate case to assert it is ignored, add the
  conformant ext:<form-d-tag>:* replacement, and add negative cases for legacy
  konfi keys and unknown subs.
- Document a pre-existing limitation surfaced while verifying this: a facet's
  kind is fixed by whichever tag is seen first, so a facet mixing concepts and
  free-text scalars loses one half, order-dependent. ambToNostr emits exactly
  that shape for a mixed amb.ext facet (the Konfi "custom value alongside
  vocabulary picks" case). Corpus impact is one event today; the wizard edit
  path is unaffected because parseKonfiTags reads the bare tag itself. Fixing
  it widens the Facet union that extensionMetadata switches on, so it is left
  out of the grammar fix deliberately.

Intended behaviour change: edufeed-app stops reading un-migrated
ext:ekw:konfi:*. This matches the no-shim ruling -- the two segmentations are
indistinguishable, so conformant consumers must ignore them rather than guess.
It makes the Task 7 migration the thing that restores those facets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A selector keyed on the presence of ext:ekw:konfi:* looks right and is wrong.
On the current corpus 8 of 25 ext-bearing events carry only bare scalars
(ext:ekw:bibleReference, ext:ekw:methodOther) and zero konfi keys. Those
events are already NIP-conformant; their defect is that the pre-fix relay
dropped 3-segment scalars from the index. Conversion runs at *index* time
(nostrlib typesense30142/replace.go), so the only thing that re-indexes them
is a re-publish. Selecting by konfi-key presence skips them and they stay
invisible on the relay after the migration is declared done -- fixed in code,
still broken in the index, and nobody looking any more. Caught by NIP-BOSS
against a claim of mine that reasoned from an aggregate and missed the split.

- Select kind-30142 under #l=ekw and take every ext-bearing event; rewrite
  konfi keys where present, re-sign unchanged where not. #l=ekw is a verified
  superset: a paginated scan of 8476 kind-30142 events (2026-07-29) found 0
  ext-bearing events lacking that label. ext:* keys are multi-char and not
  REQ-filterable; l is, so the filter moves server-side.
- Report the split in the dry run (N to rewrite / N to re-sign) and label each
  event's action, so a no-op re-sign reads as intentional rather than a bug.
- Document the scope and the deploy-before-migrate ordering at the top of the
  file: running --publish before nostrlib fix/amb-ext-grammar is live
  re-indexes through the old converter and wastes the run.
- Fix lint. The file was committed unformatted, so prettier --check failed and
  short-circuited eslint, masking 6 no-empty errors underneath. Both halves
  fixed; pnpm lint is green.

Dry run against the live relays: 25 ext-bearing under #l=ekw, 11 to rewrite,
14 to re-sign, 0 warnings -- matching the independent census exactly (6
konfi-only, 5 konfi+scalar, 8 scalar-only, 6 already clean).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Task 7 Step 1 still specified the konfi-keyed selector that skips the 8
  scalar-only events. Replace with #l=ekw + every ext-bearing event, with the
  reasoning and the expected set breakdown as a dry-run assertion.
- Task 7 Step 2: always bump created_at and re-sign so the event id changes. A
  byte-identical re-publish has a byte-identical id, and id-level dedupe exists
  in client caches and broadcast paths even though this relay's ReplaceEvent
  upserts unconditionally.
- Add Task 8 recording the parseExtensionTags grammar fix, and note in "Out of
  scope" that it fixes the grammar in place rather than routing the read path
  through nostrToAmb -- read-side convergence remains out of scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A facet was locked to a single `kind` by whichever tag arrived first, and
every later tag of the other kind was skipped. A facet carrying both
Concepts and free-text scalars therefore lost one half outright.

This is not hypothetical: `formDataToAmbExt.buildKonfiFacets` emits exactly
that shape for an `allowCustom` vocab field — the picked Concepts as
`:id`/`:prefLabel:*`/`:type` runs, then the custom string as a bare
`ext:<ns>:<facet>` tag. Concepts are always emitted first, so the loss was
deterministic: pick a vocabulary term *and* type a custom value, and the
custom value disappeared from the resource view. Only `zeitstruktur` has
`allowCustom` today, which is why the corpus shows no instance yet.

The relay already gets this right. `nostr_amb.go` accumulates concept
instances and scalars independently and concatenates them into
`ext[ns][facet]`, so the value is on the relay and correctly indexed — it
was only the app that dropped it. This ports that shape rather than
inventing a second one, so the two readers cannot drift apart again.

  Facet: { kind: 'concept'|'scalar', items }
      -> { kind: 'concept'|'scalar'|'mixed', concepts, scalars }

`items` is renamed rather than widened on purpose: every consumer breaks
visibly instead of silently keeping the old half-a-facet behaviour.

- buildExtensionCards renders a mixed facet through the scalar stack,
  concept labels first, so nothing is dropped. MetadataCardGrid renders
  `scalars` in place of `value`, so the two cannot share a card; the
  stacked list shows everything without touching the component.
- booleanFacetValue now requires kind === 'scalar'. A lone 'true' scalar
  next to concepts is not a flag, and treating it as one binned the
  concepts with it.
- summarizeExtensionFacets surfaces both halves under kind 'mixed'.

Verified: the new tests fail (15) against the previous parser and pass
against this one. Full suite 459 files / 5029 tests green, check 0 errors,
lint green.

Still outstanding, not in this commit: amb-nostr-converter's
`reconstructExt` (nostrToAmb.ts:511-519) has the identical first-tag-wins
defect and drops the scalar with no warning, so AMB JSON export is still
lossy. Same fix, same reference implementation. Owned by NIP-BOSS on
branch fix/amb-ext-grammar.
A resource whose `image` URL cannot be fetched left a grey placeholder
box with the license pill floating in it. The reported case
(kind 30142, d-tag http://cmsseniors.press.plymouth.edu/) points at
https://press.plymouth.edu/... — an NXDOMAIN host, so /api/image answers
502 and the direct URL errors too. ImageWithFallback exhausted its chain
and rendered the neutral placeholder; the "no license info" pill landed
on top of it, which read as though the missing license were the cause.
It was not: nothing in the app gates display on a license, and it should
stay that way — most harvested resources carry no kind-1063 attestation.

ResourceCover now treats an unloadable cover exactly like an absent one:
it falls through to the PDF page-1 thumbnail gate and then to TypoCover,
and the license overlay goes with the image it described. A new
`onexhausted` callback on ImageWithFallback reports that every source
stage for the current `src` has failed; it re-arms when `src` changes,
so a later cover still gets its chance.

Verified against the reported resource on a local dev server: the image
branch unmounts, no placeholder, TypoCover renders with title, author
and CC BY-NC-SA 4.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A kind 10050 is replaceable, so backfilling one is destructive if we are
wrong: a default list published on top of a real one, with a newer
created_at, is that list gone. ensureDmRelayList decided from an
EventStore read alone, which cannot tell "this user has no DM relay
list" from "we have not fetched it yet" — and three of the four DM send
sites reached it through sendWrappedDm with no gate at all. Only
membership-publish held the line, with its own copy of the check.

The gate now lives inside ensureDmRelayList, so every caller is safe by
construction and membership-publish drops its duplicate:

- waitForDmRelayCheck() exposes the DM service's settle-aware verdict as
  a promise instead of a snapshot. A caller seeing the interim 'checking'
  had to choose between skipping the backfill (user keeps no inbox) and
  publishing over a list it merely had not loaded; it can now wait for
  the check to conclude. Waiters are released on conclusion, on logout,
  and on a caller-side deadline, so none can hang.
- ensureDmRelayList publishes only on a conclusive 'absent', re-reads the
  store at write time, and goes through applesauce AddDirectMessageRelay.
  The merge does not replace the proof — that action reads the same store
  with only a 1s grace window — but it downgrades the worst case from
  "your list is gone" to "a relay was added to it", and deletes our
  hand-rolled event builder.
- The write is announced. It publishes to the user's public identity as a
  side effect of something else, so a toast points them at
  Settings › DM Relays. Suppressed on the assistant-hint path, where the
  user tapped the card themselves and it flips to 'done' in front of them.
- sendWrappedDm no longer awaits the sender backfill. Our own 10050 is
  where replies land and never routes the outgoing wrap, so the send waits
  on the recipient prefetch alone and fires the sender backfill alongside
  it. That asymmetry is what makes insisting on proof affordable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge branch 'fix/issue-51-cover-fallback' into dev
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
795d4e6d27
Every issue-51 case rendered ResourceCover at size="full", so the
list-variant card was covered only by an argument from reading the code:
`size` reaches ImageWithFallback as the proxy dimension preset and
nothing else, so it cannot change the stage chain or when that chain is
exhausted. True, but unproven — and the branch is not reachable from
Discover today (no list/grid toggle), so it went untested by hand during
review too.

Two cases now pin it: the typo fallback fires at size="thumbnail", and
the caution badge drops with the image there as well. The second asserts
the badge carries no "No license info" label before failing the stages,
so it cannot silently degrade into a second test of the pill variant if
the variant mapping ever changes.

Both fail when ResourceCover's onexhausted handler is stubbed out, so
they test the fix rather than the fixture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge branch 'test/thumbnail-cover-fallback' into dev
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m30s
f734e45425
The fan-out published one copy per admin in a sequential loop that threw
on the first failure. Two consequences, one found by testing and one by
reading it back: the first admin's relays being unreachable meant the
remaining admins were never even tried, and anything that killed the page
mid-loop left admin 1 holding an application admin 2 never saw — with no
confirmation to the applicant either way.

Signing still happens up front and in order (it is local work, and NIP-07
signers serialize anyway), so a failure while encrypting cannot deliver a
partial set. The publishes then run together under allSettled, not all:
one admin's outcome must not cancel another's, and every result is needed
to report on.

Success is now "at least one admin has it", because any single admin can
act on an application — but a partial fan-out is no longer silent. It
warns, and only the copies a relay actually accepted are mirrored into
the event store; storing a copy nobody took would show the applicant an
application that does not exist anywhere else.

publishToRelays drops its hand-rolled Promise.allSettled for applesauce's
own pool.publish, which does the same per-relay fan-out with the same
error isolation (errorToPublishResponse) and adds retries for a relay we
failed to *reach*. Its defaults are overridden deliberately: 30s behind a
submit button is far too long, and retries are capped so a dead relay set
cannot stack 3 x 30s. A relay answering OK:false is not retried — that is
a decision, not a transport failure. The looser "no response object means
success" check is gone with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat: NIP-101 forms + AMB-serializer convergence (conformant Konfi ext namespace) (#50)
Some checks failed
Build and Push Docker Image / build (push) Failing after 4m38s
242a22fb84
Merges feature/nostr-metadata-forms into dev.

Normative left-anchored ext: grammar, reverse-DNS Konfi namespace
(org.edufeed.ekw.konfi), mixed ext facets keeping concepts AND scalars,
and the Task 7 migration script.

NOTE: the deployed relay (nostrlib edufeed@f4729e8, nostr_amb.go:877)
still drops bare scalar ext tags. The Konfi re-publish must wait for the
nostrlib deploy.
Merge branch 'feature/multi-admin-membership' into dev
Some checks failed
Build and Push Docker Image / build (push) Failing after 4m45s
2f507ff506
Three conflicts, all in the membership submit path, because PR #50 and this
branch rewrote the same code from different directions.

- MembershipApplicationForm prefill: dev routed decryption through the forms
  nip44DecryptWith helper but against a single `adminPubkey`; this branch used
  the p-tag of the copy being read but called signer.nip44.decrypt directly.
  Kept both halves — the helper, against the per-copy counterparty. With
  several admins there is no single pubkey to decrypt against.

- MembershipApplicationForm submit: dev built one tag set and one ciphertext
  for one admin. That whole block is superseded: NIP-44 is pairwise, so tags
  and ciphertext are built per admin in the fan-out below.

- The guard. This branch aborted on hasNip44 (nested surface, encrypt AND
  decrypt); dev let nip44EncryptWith throw. Neither fit: signerHasNip44 asks
  only about decryption, which is right at the prefill site and wrong at a
  site that encrypts — a decrypt-only signer would pass it and throw
  mid-submit. Added signerCanNip44Encrypt next to it and gated on that, so
  each site asks about the operation it performs.

- MembershipCard: the inline decrypt + .well-known lookup dev still carries
  now lives in the membership-grant store this branch added; the auto-merged
  half of the file already read from it, so keeping dev's hunk would have left
  code assigning to what is now a $derived.

Also completes the loader/relay-helper mocks in MembershipApplyModal.test.js,
which this branch added before PR #50's eager adapter imports reached them —
the same breakage 57d3d4a0 fixed for the sibling suites.

Verified on the merged tree: lint clean, svelte-check 0 errors, 5137 tests
with 5134 passing. The three failures are all pre-existing on dev and not
from this merge: ResourceFormWizard.edit-prefill (unreliable in both
directions for two days) and two publish-forms-build cases that fail
deterministically against scripts/ and forms/format.js byte-identical to
origin/dev.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Kind 1069 is a regular event, so re-submitting a membership application
adds a copy rather than replacing the original. Both admin-facing
surfaces counted and rendered raw events, so an applicant who edited
their application appeared once per submission.

The badge count was the visible symptom; the sharper problem is in the
approvals panel. `approve()` provisions `decrypted.get(response.id)
.wished_handle` — the handle from *that row*. With a superseded row
still listed and still actionable, an admin approving the wrong card
provisions the handle the applicant asked for before they edited it,
and `/api/nip05` has no reason to reject it.

Both surfaces now go through selectAdminApplications(), which does the
p-tag narrowing they already duplicated plus a newest-per-applicant
reduction, ties broken by event id so the choice does not depend on
relay arrival order. The reduction runs before the rejected-id filter:
rejection means "reject this applicant", so rejecting the row the admin
saw must not resurface an older submission from the same person.

Reported by TestOER against dev @ 2f507ff5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The warning that an application reached only some admins was set and then
destroyed on the same tick. `submitted = true; onsubmitted?.()` runs
back-to-back, and MembershipApplyModal's onsubmitted closed the modal —
so the `{:else if submitted}` branch that renders the warning became
reachable exactly as ModalManager unmounted it. It never painted a frame.

That is the only signal an applicant gets that a reviewer was missed and
the review may sit longer than usual. The surface behind the modal cannot
carry it: it flips to "waiting for review" off the mirrored event, which
a partial fan-out still produces.

onsubmitted now receives the delivery outcome, and the modal stays open
on a partial so the applicant can read the warning and dismiss it
themselves. A clean submit closes as before. SignupModal is unaffected —
it keeps the form mounted under `currentStep === 5`, so the warning
already rendered there.

The suite was green on this because MembershipApplicationForm.test.js
renders with no onsubmitted at all, so nothing tore the form down. The
new coverage is at the modal, where the lifecycle actually exists.

Found by TestOER against dev @ 2f507ff5, using a relay proxy that answers
OK: false to events p-tagged to the second admin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ext facet filters in the learning view could not match anything. The
filter key carried the kind, the author pubkey and the d-tag —
`30168:<pub>:<d>:<fieldId>` — but `ns` and `facet` MUST NOT contain `:`
under the NIP-AMB grammar, so the key had no valid reading. Toggling a
facet chip narrowed the result set to zero instead of filtering it.

The correct key comes from the write path rather than from guessing:
`formValuesToAmbJson.js` stores a form-driven ext value at
`amb.ext[form.dTag][field.id]`, which serializes to the tag key
`ext:<dTag>:<fieldId>`. `ambJsonToFormValues.js` inverts exactly that.
So the filter key is `<dTag>:<fieldId>`.

Three places built or asserted the stale shape; the issue named only the
first two:

- `searchQueryBuilder.js` — the two helpers
- `LearningContentFilters.svelte:extKeyFor()` — the live caller, which
  fed the helpers the stale key, so fixing only the helpers would have
  changed nothing
- both test files, which asserted the stale shape *as correct*

`extPathToTagKey()` now returns null for anything that is not exactly two
non-empty colon-free segments, and callers skip it. A malformed key
emitting no filter is much better than one emitting a filter that cannot
match: the second reads to the user as "no results" rather than "broken".

Ext facets are no longer put in the NIP-50 `search` string at all. The
dot path was removed rather than repaired — with reverse-DNS namespaces
`ext.org.edufeed.ekw.konfi.themen.id` gives no way to tell where the
namespace ends and the facet begins. The exact-match `#ext:` tag filter
is what the relay indexes.

Added a test that runs the UI's emitted key through
buildSearchFilterObject, since asserting the two halves separately is
what let them drift apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`isPrivateIp()` guarded every server-side fetch path with string
prefixes, so a long list of private ranges walked straight through. A
verbatim replay of the two old implementations against the fixtures in
this commit lets 16 of 16 must-block addresses reach `fetch` —
`169.254.169.254`, CGNAT, `[::ffff:127.0.0.1]`, ULA, v6 link-local,
multicast, NAT64, `127.255.255.254`, `app.localhost`, `db.internal` — and
wrongly blocks 3 of 4 public ones.

Addresses are now parsed with `node:net` and compared as masked integers
against a CIDR table. IPv4-mapped, IPv4-compatible and NAT64-embedded v6
addresses are unwrapped to their v4 payload before the check, so
`[::ffff:169.254.169.254]` cannot smuggle link-local past an IPv4-only
comparison. A syntactically valid but unexpandable v6 address fails
closed.

There were two implementations, not the one the issue names.
`src/routes/api/reader/+server.js` carried its own weaker copy: it
blocked all of `172.*` (breaking public 172.32+) while missing the
bracketed `[::1]` form that `parsedUrl.hostname` actually returns, so
`http://[::1]/` was reachable through /api/reader today. Fixing only the
shared helper would have left that route on the old heuristics. The copy
is deleted; all five call sites — /api/image, /api/oer/asset, /api/pdf,
/api/pdf-thumbnail, /api/reader — now share one guard.

DNS resolution is the other half. `isBlockedHost()` runs the synchronous
check and then `dns.lookup({ all: true })`, rejecting if any answer is
private, which closes the DNS-rebinding hole. A lookup *failure* stays
fail-open on purpose: a name that does not resolve cannot be fetched
either, so failing closed would only turn resolver blips into rejected
requests. The residual TOCTOU gap needs connect-time pinning and is
documented in place.

The issue's "exotic encodings" bullet needed no code. The WHATWG parser
normalises decimal, hex, octal and short-form IPv4 to a dotted quad
before our code sees the hostname; asserted in the tests so it stays
true.

Tests build every fixture by running the string through `new URL()`
rather than hand-typing the expected hostname, because the parser
hex-compresses IPv4-mapped v6 — `http://[::ffff:127.0.0.1]/` arrives as
`[::ffff:7f00:1]`. A guard written against the dotted form passes a
hand-typed test and still lets the real URL through. Boundary controls
(172.15/172.32, 100.63/100.128, `[::ffff:8.8.8.8]`) keep "blocks
everything" from passing, and the redirect tests assert the internal URL
was never requested rather than only that a throw happened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to the same issue, from TestOER's adversarial pass on the first
commit. None of these were reachable on this host — connect fails outright
for the v6 forms, and every route goes through isBlockedHost, so the
hostname nit exposed nothing — but three of the four are one line each and
the first is a near-miss of a form the original commit did handle.

- `::ffff:0:a.b.c.d` (RFC2765 IPv4-translated, ::ffff:0:0/96) carries its
  0xffff at bytes 8-9 rather than 10-11, so it slipped past both
  zeroPrefix(10) and zeroPrefix(12). Now unwrapped like the mapped form.
- 6to4 (2002::/16) and Teredo (2001::/32) embed an IPv4 destination
  mid-address. Blocked wholesale rather than unwrapped: nothing this app
  legitimately fetches sits behind a v6 transition tunnel, and a blanket
  refusal removes a class of "did we unwrap the right four bytes"
  reasoning error from a security check. 2001:db8:: and 2003:: are pinned
  as controls so the prefix test cannot widen into global unicast.
- `fec0::/10` site-local. Deprecated by RFC3879 but still routed on some
  networks, and previously asserted as *allowed* by the test suite.
- `URL.hostname` preserves a trailing dot, so `localhost.` and
  `printer.local.` — the fully-qualified forms of names this module
  blocks, resolving to the same addresses — missed both the exact match
  and the suffix match. One dot is now stripped before comparison.

`resolvesToPrivateIp` deliberately still passes the hostname to the
resolver with its trailing dot intact: the dot is meaningful there
(absolute vs search-domain-suffixed), so normalising it away could change
which addresses get checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six e2e files hard-coded the daisyUI speed-dial markup and all six broke
together when GlobalFAB replaced it. Routed through three shared helpers
in fixtures.js — FAB_TRIGGER, openCreateHub(), clickCreateAction() — so
the next redesign touches one place instead of six.

Two of the issue's four bullets needed a semantic change rather than a
selector swap:

- `button[data-tip="Create Event"]` was wrong twice over. `data-tip` now
  carries the action *description*, not its label, and is absent entirely
  for actions with no description — so the poll and learning-content
  tiles have no `data-tip` at all. Tiles are keyed on `aria-label` (the
  action's `ariaLabel` in create-actions.js): 'Create new event',
  'Share learning content', 'Create poll'.

- "edit button hidden for non-owner" asserted the *trigger* was
  invisible. EventContextMenu renders for everyone, because it also
  carries Copy link / Copy event ID; only the author actions are
  conditional. Swapping the selector alone would have failed on a correct
  build. The assertion moved to the Edit item, with a positive control
  that the menu did open — so a count of 0 means "no Edit item", not
  "no menu".

The event menu itself: the detail page routes through
CalendarEventDetailView -> DetailHeader -> EventContextMenu, whose
trigger is `aria-label="Event menu"`. EventManagementActions
("Manage event") still exists but only inside CalendarEventDetailsModal,
which the detail page never mounts — so the old selector was not renamed,
it was orphaned. The item labels changed too: EventContextMenu uses
m.common_edit()/m.common_delete() ('Edit'/'Delete'), not the old
'Delete Event'. The confirmation dialog still reads 'Delete Event?'.
Items are now matched by label instead of `.first()`, which would
silently click "Share to communities" for a non-owner.

layout-consistency's FAB test could not be fixed by selector swap either:
it asserted the FAB sat in a `sticky bottom-0 h-0 mt-auto` wrapper inside
<main>. GlobalFAB is mounted at the layout root, outside <main>, and
positioned `fixed`, so both `main .fab` and the wrapper-geometry
assertions were unreachable. Rewritten around the property the regression
was actually about — on a page too short to scroll, the button is still at
the bottom of the screen — plus explicit checks that it is outside <main>
and viewport-fixed.

Also corrected two comments that documented removed behaviour (poll-flow's
viewport rationale, MainContentArea's claim that GlobalFAB is anchored to
<main>) and the COVERAGE.md row describing the old data-tip assertion.

Verified: all 316 tests across 38 e2e files parse and register with
`playwright test --list`, unit suite 5153/5153 (468 files), lint clean.
The browser run against the Docker relay stack is TestOER's pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both from TestOER's browser run of the previous commit — 13 of 20 failed,
all through one root cause.

`GlobalFAB`'s "Suggested" section maps route-suggested ids back onto the
*same* action objects the "Create" section lists (`suggestedActions` looks
each id up in `visibleActions`), so on `/calendar` the identical
'Create new event' button renders twice inside the sheet. The `button`
qualifier added last commit correctly separated the trigger from the
sheet, but the remaining ambiguity is inside the sheet and is
route-dependent — which is why `playwright test --list` could not see it.

`.first()` is safe here for a reason worth writing down, because the same
operator was deliberately *removed* from the event context menu in the
previous commit: there the matches were genuinely different actions and
taking the first silently clicked "Share to communities". Here both
matches carry the same aria-label and both call `runAction` on the same
object, so either click is equivalent. The comment says so at the call
site.

The description assertion is now scoped to the description card instead
of a page-wide getByText. The summary renders through MarkdownRenderer,
so the wrapper and the paragraph it produces both contain the text and
the bare matcher resolved to two elements. Anchoring on the card also
makes the assertion state what it means — the summary is in the
description card, not merely somewhere on the page.

Not fixed here: three update-flow tests fail on content rather than
locators. Out of scope for a selector issue, and filed separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Left failing rather than skipped, and now says so at the call site. A
`test.skip` would make the suite green while hiding a bug that nobody had
seen until #39 fixed the selectors in front of it, and the next person to
run these should not have to re-derive that they are not selector bugs.

Comments only — no assertion or locator changed. 316 tests still register.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The port was a constant while `reuseExistingServer` is true outside CI, so a
preview server another worktree left running on 14173 is *reused* rather than
replaced — the run then exercises that worktree's build, with no error and no
warning, and every result is attributed to the wrong tree.

That is not hypothetical: 14173 is currently held by a `vite preview` from
.worktrees/testoer-pr52, and two concurrent e2e runs in this repo had no way
to isolate from each other without hand-editing the config.

Default is unchanged (14173), so existing invocations behave identically.
Verified both: unset -> 14173, E2E_PORT=14999 -> 14999 in baseURL, webServer
port and ORIGIN; 316 tests still register.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Editing a calendar event published the replacement to the relays but never
offered it to the local cache, so every reload of the detail page rendered
the pre-edit version indefinitely.

The detail route is `ssr = false` and loads via applesauce's addressLoader,
whose first step is the IndexedDB cache. A cache hit ends the loading
sequence — relay hints, extra relays and lookup relays are never tried — so
the page shows whatever the cache holds. The cache is fed from
`eventStore.insert$`. `createEvent` goes through `publishEventOptimistic`,
which adds to the EventStore; `updateEvent` goes through `publishEvent`,
which does not, and `calendar-actions.svelte.js` did not import eventStore
at all. Updating `calendarStore` is not a substitute: that is the list
view's state, not the detail page's read path.

Add the signed event to the EventStore after a successful publish. Not
`publishEventOptimistic`: its failure path calls `eventStore.remove`, and
the cache pipeline is insert-only (`persistEventsToCache`, batchTime 1s),
so for an addressable kind a rejected publish would leave a phantom in
IndexedDB that overwrites the last good version and — per the cache-hit
short-circuit above — is never corrected from a relay. Gating an explicit
add on success is the shape `EditProfileModal` and `pin-list-service`
already use.

The add is best-effort: `eventStore.add` validates and throws on a
malformed event, and by then the publish has already landed, so a
cache-write failure must not be reported as a failed update.

Reported and measured by TestOER: the relay held exactly one event at the
coordinate, carrying the new title, while the page rendered the old one.

Refs: #62
The EventStore write alone was necessary but not sufficient. A replacement
that lands in the same wall-clock second as its predecessor is dropped by
three independent layers, with two different tie-breaks:

  - relays (NIP-01): on equal created_at the LOWER id wins — a coin flip,
    so the edit survives on the relay only about half the time;
  - applesauce EventStore: same rule, `incomingBeatsWinner` in
    event-store/event-store.js;
  - nostr-idb: strict `event.created_at > existing` in database/insert.js,
    so on a tie the IDB write is ALWAYS rejected.

Only the relay layer is a coin flip. The cache is deterministically stale,
and because the IDB cache is the first step of applesauce's address-loader
sequence and a cache hit ends that sequence, no relay ever corrects it. So
a same-second edit reads to the user as "the save did nothing" even in the
runs where the relay accepted the replacement.

Stamp created_at as max(now, existing.created_at + 1). The bump only closes
a tie: an edit made a second or more after the event it replaces still gets
wall-clock time.

Reachable outside the tests by a user editing straight after creating, and
by two tabs replacing the same coordinate.

Diagnosed by TestOER from a full relay recording of a failing e2e run: in
the one attempt that passed, the create's d-tag was minted 23ms before a
second boundary; in the five that failed there was 232-474ms of headroom,
so create and update shared a second.

Refs: #62
The last red test in the calendar-editing set was not the stale read. Once
the update actually reached the page, the summary rendered through
MarkdownRenderer (CalendarEventDetailView.svelte:218-224) and the page-wide
getByText resolved to two elements — the wrapper and the paragraph it
produces. Same fix already applied one file over at
calendar-creation.test.js:165.

Worth recording that the assertion failed two different ways: "element(s)
not found" while #62 was live (the pre-edit event had no description, so no
card rendered at all) and "strict mode violation" once the read was fresh.
The change in failure mode is what proved the read had been fixed.

Also refresh two stale comments in the same file: the KNOWN FAILING blocks
now describe what these tests guard, and the claim that the modal triggers
window.location.reload() is corrected — it calls invalidateAll(), which
preserves the JS context, so the explicit page.goto() is the only hard
navigation in that test.

Refs: #62
Fixes the stale-read after editing a calendar event.

Two defects, both required for the symptom:
1. updateEvent published without eventStore.add, so IndexedDB kept the pre-edit version and applesauce's address loader ends its sequence on a cache hit — the relay was never asked.
2. applesauce unixNow() rounds, so a create and a fast edit can share a created_at second; a tie is dropped by nostr-idb addEvents (strict >), which deterministically keeps the old event.

Verified by TestOER: 20/0 twice at 961c3a82, first-attempt passes, no retries or flakes.

Closes #62
Toggling all-day on an existing calendar event changed the NIP-52 kind
(31922 date-based <-> 31923 time-based) while keeping the d-tag. A
replaceable event is addressed by (kind, pubkey, d-tag), so the new kind
is a NEW COORDINATE: the original was never replaced.

Reproduced in a browser by TestOER at b82bfe9c, twice — two live events at
one d-tag on both relays, the original byte-identical, and the page still
rendering the pre-edit title with no error shown. That is the same
user-visible symptom as #62 ("my edit did nothing"), but no cache fix can
reach it because the edit genuinely went somewhere else.

The kind change is forced by the spec, so keeping the existing kind and
clearing the time fields is not an option: NIP-52 requires `start` to be
YYYY-MM-DD on 31922 and a unix timestamp (plus a `D` tag) on 31923, so
there is no legal way to express an all-day event as a 31923.

Delete-and-recreate was rejected as the remedy. NIP-52 calendars (31924)
reference their events by `a` = <kind>:<pubkey>:<d>, and that list is held
by the calendar owner, who need not be the person editing — this client
cannot re-point references it cannot sign. The NIP also states it "is
intentionally not defining what happens if a calendar event changes after
an RSVP is submitted", so there is no spec-blessed migration to follow.

Refusing is the only option that cannot corrupt anything, and it takes
nothing away that works today: the toggle never produced a working edit.

- updateEvent throws before signing, publishing or touching calendarStore,
  so no second event can be created and no phantom edit is shown.
- The type selector is disabled in edit mode with an explanation, so the
  user does not reach a control that cannot work.

Five unit cases; with the guard reverted, 4 of the 5 fail and the fifth is
the negative control that an ordinary same-kind edit still publishes.

Refs #65
Toggling all-day on an existing calendar event changed the NIP-52 kind (31922 <-> 31923) while keeping the d-tag, so the app published to a new (kind, pubkey, d) coordinate instead of replacing the original. Two live events, and the user saw a save that silently did nothing.

updateEvent now refuses a kind change before signing, publishing or touching calendarStore, and the type selector is disabled in edit mode.

Verified in a browser by TestOER at 2df1c8bd, twice: buttons disabled in edit mode and enabled in create mode; a DOM-forced kind change publishes nothing, leaving exactly one event with the same id and created_at; and ordinary same-kind edits still publish for both 31922 and 31923.

Closes #65
Siblings of #62: publish sites that never reach the IndexedDB cache, plus
the failure path that reaches it and should not.

Why any of this matters: the cache is the FIRST step of applesauce's
addressPointerLoadingSequence and a cache hit ENDS the sequence — relay
hints, additional relays and lookup relays are never tried. So for a
cacheable kind, whatever is in IDB is what the app shows, and a relay
cannot correct it. The cache is fed only from `eventStore.insert$`
(event-cache.svelte.js), and `publishEvent` never touches the EventStore.

Sites fixed

- relay-settings-service.js (kind 10002) had zero eventStore references.
  This is the relay list itself, so a stale read there mis-routes every
  subsequent query — the blast radius is not one screen.
- calendar-actions createCalendar (kind 31924) had no add either. Milder:
  a create has no stale prior version to be served, only "missing until a
  relay round-trip".
- publishEventOptimistic removed a failed event from the EventStore but
  could not reach IDB, because the cache pipeline was insert-only. For an
  addressable kind that is worse than a leak: nostr-idb keys by
  `kind:pubkey:d`, so the phantom OVERWRITES the last good version at that
  address and the cache-hit short-circuit means no relay is ever asked to
  correct it. A failed edit would render forever.
- calendarActions.deleteEvent was dead code (no callers; the UI goes
  through helpers/eventDeletion.js) that published a kind 5 with neither
  an eventStore.add nor a cacheDeletion — exactly the failure the
  cacheDeletion doc comment warns about. Removed so it is not copied.

The phantom fix is two guards, because the write is buffered and the two
orderings fail differently:

- uncacheEvent() deletes the entry from IDB when the publish has already
  flushed. It keys by getEventUID, the key nostr-idb actually writes
  under — passing event.id would silently match nothing for exactly the
  replaceable kinds that matter — and deletes only when the stored entry
  IS that event, so a newer version that legitimately took the address is
  never dropped.
- the write callback re-checks eventStore.hasEvent at FLUSH time, for the
  case where the failure lands inside the batch window. Also stops
  superseded versions and deleted events being persisted.

Shared helper

nextCreatedAt() and cachePublishedEvent() move the two #62 guards out of
calendar-actions into helpers/replaceableUpdates.js, with the reasoning
attached, so a new publish site inherits them instead of re-deriving them.

nextCreatedAt is then adopted at three further update paths that had the
#62 bug and no guard: updateWiki (30818), updateArticle (30023) and
updateResource (30142). All three publish optimistically, so a same-second
edit ties — and on a tie nostr-idb keeps the OLD version deterministically
(strict `>` in database/insert.js), not half the time. Ties are not exotic
because applesauce's unixNow() rounds rather than floors.

Not measured: the discriminating experiment for the phantom path is
dead-relay -> save -> reload in a browser, which is TestOER's to run.
updateResource has no direct unit test — educational-actions.svelte.js
cannot be imported in the node test env (established in
educational-actions-tags.test.js), so that one adoption rests on the
helper's own tests and on `pnpm check`.

Verification: 473 files / 5213 tests pass (+20), lint clean, `pnpm check`
0 errors. Each new event-cache test was confirmed load-bearing by
reverting the guard it covers and watching it fail. The non-zero exit from
`pnpm test` is the known GlobalFAB EnvironmentTeardownError flake, not a
test failure.

Refs #64
Follow-up to 13ac018d, from TestOER's browser measurement on PR #69.

Deleting the phantom was not enough. Adding a replacement evicts its
predecessor from the EventStore (keepOldVersions is off) and, once the
cache batch flushes, overwrites it in IDB too — nostr-idb keys
replaceable events by `kind:pubkey:d`. So `uncacheEvent` left the address
EMPTY, not restored. Measured in a browser across four dead-relay modes,
9/9 trials EMPTY, and user-visibly that is a 404 on a resource that
exists.

publishEventOptimistic now captures the version being replaced BEFORE the
optimistic add — the only moment it is still reachable — and re-adds it
after the phantom is un-cached. Ordering is load-bearing: nostr-idb only
writes a replaceable event when it is newer than the entry at its
address, so restoring first is silently rejected.

Two claims corrected in the comments, both measured by TestOER with
negative controls:

- The `hasEvent` flush-time guard does NOT fire on the failed-publish
  path. The fastest possible failure reports at 1006-1015ms (5/5) because
  getPublishRelays does a relay-list lookup before publishing starts,
  while the batch flushes at 1000ms. Deleting the guard left outcomes
  identical 5/5. It is kept for the invariant it does cover — superseded
  replaceable versions and NIP-09 deletions — and the comment and the
  test name now say which.
- uncacheEvent is the whole of the #64 fix: disabling it leaks the
  phantom 4/4.

Verification: 5216/5222 pass. The 6 failures are five DM/inbox/gift-wrap
files that fail IDENTICALLY on unmodified dev under the same load —
confirmed by running dev's full suite twice, once clean and once with the
same five red. Load-dependent 5s/30s timeouts, same family as the known
GlobalFAB flake, not this branch. lint clean, `pnpm check` 0 errors.

Each new assertion confirmed load-bearing by reverting the code it
covers: no restore -> 2 fail; capture moved after the add -> 1 fail.

Refs #64
The restore merged in #69 reaches memory but not IndexedDB whenever the
predecessor came from the cache — which is every path with a page reload
in it (open app, open resource, edit). So the 404 it was meant to prevent
is still there on dev, measured by TestOER at 59b2de15.

Cause is one line of applesauce:

    // applesauce-core/dist/helpers/event-cache.js:20
    filter((e) => !isFromCache(e))

An event loaded through `cacheRequest` carries `Symbol.for('from-cache')`.
`publishEventOptimistic` re-adds that same object, so `persistEventsToCache`
skips it: `eventStore.add` restores the UI and the durable write silently
never happens. The marker's premise — it is already in the cache, no need
to write it back — was true when it was set and is false by the time we
restore, because `uncacheEvent` has just deleted that row.

Fixed with a direct write rather than by clearing the marker. `recacheEvent`
puts the predecessor straight into IDB, after the un-cache and alongside the
existing `eventStore.add`. Chosen over stripping the symbol because it does
not depend on an applesauce internal keeping its current shape — and because
`{...previous}` does NOT strip it: object spread copies own enumerable
symbol properties. There is a test asserting exactly that, so the tempting
one-liner cannot be reintroduced by accident.

`recacheEvent` honours CACHEABLE_KINDS, which the insert$ writer applies
too — a direct write must not smuggle in a kind we deliberately do not
persist.

Verification: 474 files / 5231 tests pass, exit 0, at a clean tree on
59b2de15. lint clean, `pnpm check` 0 errors. Both new assertions confirmed
load-bearing by reverting what they cover: no `recacheEvent` call -> 2 fail;
kind guard removed -> 1 fail. The from-cache drop is also pinned directly
against the real pipeline — an event carrying the marker is proved NOT to
reach IDB through insert$, with a keeper event in the same batch so the
absence cannot pass vacuously.

NOT re-measured in a browser. TestOER's reload scenario at 59b2de15 is what
found this, and the same scenario is what should confirm it.

Refs #64
Carried both bugs #62 and #64 fixed elsewhere. Measured at e0aa0aba by
TestOER: a same-second edit is lost while the app reports success (memory
V2 / IDB V1, then memory EMPTY after reload), and a failed publish leaves
a phantom in IDB that survives a reload with the relay down.

Two changes, both the pattern the rest of the app already uses:

- created_at: nextCreatedAt(resourceEvent) in edit mode. Without it a
  replacement can tie with the version it replaces, and on a tie nostr-idb
  keeps the OLD one deterministically.
- eventStore.add moved to AFTER a successful publish, via
  cachePublishedEvent. publishEvent has no failure path — unlike
  publishEventOptimistic it never removes or un-caches — so an add placed
  before it has nothing to undo it when no relay accepts. Kind 30142 is
  cacheable, so that left a version existing on no relay cached at its
  address, and a cache hit ends the address loader before any relay is
  asked.

A total publish failure now surfaces as an error instead of navigating to
the new naddr as though it had worked. Hardcoded German matches the file
convention (zero paraglide imports, 'Bitte anmelden.' /
'Veröffentlichung fehlgeschlagen' already inline).

Why #64's sweep missed this: that sweep looked for publish sites with NO
eventStore.add. This one has one, on the wrong side of the publish —
grepping for an absence cannot find a misordering. TestOER's framing.

Reachability traced, not assumed: the route passes editNaddr to this
component and the wizard's own edit fetch is explicitly skipped when
templateNaddr is set, so this is the designed edit path for any resource
authored from a kind-30168 form template.

Verification: 474 files / 5231 tests pass, exit 0, at a clean tree on
e0aa0aba. lint clean, pnpm check 0 errors.

NOT covered by a new test. Driving this component's submit needs
decodeFormNaddr, parseFormTemplate, addressLoader, the event factory,
publishEvent and buildTemplateResourceSubmission all mocked; the two
behaviours themselves are already unit-tested on the helper
(replaceableUpdates), but that this component calls them is unproven in
CI. The browser measurement that found it (T1/T2) is what should confirm
it.

Closes #72
The hover badge over a resource cover read "📎 1 Materialien verlinkt" — a
count, with the wrong plural. It now reads "📎 PDF · 2,4 MB" for a single
material, from data already on the event: `encoding:encodingFormat` and
`encoding:contentSize`. No format change, no migration, no network call.

New pure helper `linkedMaterials.js`:

- `materialTypeFromMime` maps mime → material type. It returns null, not
  'file', for the unknown *and* for `application/octet-stream`, because the
  publish path defaults to octet-stream when the uploader did not know — so
  treating it as a known type would have every unlabelled upload claim to be
  a binary.
- `materialTypeFromFilename` is the fallback, matching the mime-first,
  extension-second convention in EncodingPreview.svelte and
  pdfThumbnailGate.js. It strips the query and fragment first: `?file=x.pdf`
  is not evidence about the resource being fetched.
- `formatMaterialSize` treats 0 as unknown rather than as an empty file,
  because 0 is exactly what a missing `encoding:contentSize` parses to.

The positional-pairing hazard the publish path documents is handled rather
than inherited. `encoding:contentUrl`, `:encodingFormat` and `:contentSize`
are three lists aligned by *position*, and the optional two are emitted only
when known — so two files with one format have no recoverable mapping. When
the lists do not line up, the ambiguous list is dropped entirely and the type
comes from the URL extension, which is per-item and cannot be mis-attributed.
A single file is never ambiguous.

Scope, deliberately: several materials still fall back to the count. A
per-item list does not fit in a badge, and a type breakdown ("2 PDFs, 1
image") needs a plural form per type per language — see below. The resource
page already lists them.

No ICU plurals in this project, and not for want of trying: paraglide 2.16
with plugin-message-format v4 flattens a `match` object into *separate*
messages (`key.match.count=other`) with no runtime selector, and the
declarations/selectors/variants shape fails to compile outright. The existing
workaround — a `{plural}` parameter the caller fills in — cannot serve two
languages from one placeholder, and does not: CalendarView passes 's' into a
German string that wants 'en'. So the singular is its own key.

Tests: 23 for the helper, 8 for the rendered badge. The badge ones assert
rendered text, not the helper, because a correct helper behind a badge that
shows something else is precisely how the plural bug survived; one of them
pins the exact reported wording as forbidden. Negative control: reverting the
badge to the count-only expression fails 5 of the 8.

Suite 5211/5211, 469 files (dev: 5180/5180, 468) — +31, exactly the new
tests. Lint clean, `pnpm check` 0 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Slice 2 of #57. The badge now reads "PDF · 12 Seiten · 2,4 MB" where slice 1
could only say "PDF · 2,4 MB". Type and size come off the event; AMB's
`encoding:*` carries no page count, so it has to be read from the file.

Cheap in the common case. `/api/pdf-thumbnail` already parses the document to
render the cover, so `renderPdfThumbnail` now returns `numPages` alongside the
WebP and the endpoint writes it to a `pages.json` sidecar. A card whose cover
rendered answers from disk — no second fetch, no second parse. The
fetch-and-parse path in `/api/pdf-info` is only for files no thumbnail was
ever rendered for, and it skips canvas and sharp entirely.

Guardrails are SHARED, not reimplemented — `$lib/server/pdfSource.js` now owns
the fetch, the size cap, the redirect re-validation, the content-type check
and the cache paths for both endpoints. That is the mistake #31 fixed in
`/api/reader`: a second private copy of the private-IP check drifted from the
first, and the weaker copy is what shipped. One implementation.

Rights model is unchanged and deliberately reused: reading a page count means
fetching the file, which is the same act `canDeriveThumbnail` exists to
authorise, so the card gates on it before asking. Attached files only — an `r`
link carries no attestation, so an external PDF gets no page count.

Fetched on first pointerenter rather than on render: the badge is hover-only,
and a feed must not fire a request per card just to sit there. Memoised per
URL, including failures — a card must not retry a 404 on every hover.

The cover div is deliberately given no ARIA role. It is not interactive; the
handler is a cache warm-up and the badge renders type and size without it, so
a user who never produces a pointer event loses nothing that `group-hover`
was not already hiding.

Verification at a clean tree on dev e0aa0aba:

  477 files / 5279 tests pass (+48)
  lint clean, pnpm check 0 errors (4 warnings, all pre-existing fixtures)

New tests confirmed load-bearing by reverting the code they cover:

  sidecar validation weakened to `if (cached)`  -> 1 fail (serves numPages: 0)
  failures no longer memoised                   -> 1 fail (retries the 404)

The cross-endpoint claim is pinned too: render the cover, then ask for the
count, and assert exactly ONE upstream fetch — if the two endpoints ever
disagree on the cache key, that test fails rather than silently doubling the
traffic.

Still open on #57, deliberately not in this slice: external `r` links (a
policy call — extension guess vs an HTTP HEAD per card), PPTX slide counts (a
new server dependency), and what the badge shows for several items.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Slices 1+2 of #57. Browser-verified by TestOER at 88776df9: sidecar handoff (trials A/B/C at an instrumented file host) and the rights gate in both directions.
Replies written by other Nostr clients are kind 1 (NIP-10), not kind 1111
(NIP-22). The thread view already renders both — createCommentLoaderForEvent
merges a kind 1 #e filter for kind 1 roots — but the inbox only ever queried
kinds 1070, 1069, 7, 9 and 1111, so a reply to your own note was structurally
invisible there.

Add kind 1 to the p-tagged notification filter and classify it: with an
e-tag it is a 'reply', without one a 'mention'. Both link to the note's
nevent, which the [nevent] route renders as a thread.

The eventStore model filters were a hand-copied duplicate of the loader
filters — exactly how the two drifted apart — so derive them from
buildMainFilter instead and pin that with a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge branch 'worktree-fix+inbox-kind1-replies' into dev
Some checks failed
Build and Push Docker Image / build (push) Failing after 5m5s
cc4368f8c1
fix(inbox): surface kind 1 replies and note mentions
laoc added 146 commits 2026-08-07 07:56:54 +00:00
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds PrivateChannelsView (rail + ChannelStatePane empty/founding/syncing/
removed/no-channels states) wired into MainContentArea for the community
"channels" tab, plus the associated message keys. Modals for create/invite/
members/explainer/backup/inbox and the real chat pane are stubbed out with
TODO(task-N) markers — they land in Tasks 9-14/10.

Carries forward the Task 7 review fix: useConcordCommunity's `accessible`
flag was derived from community.material.channels, but receiveChannelKeys()
(the mid-session channel-key grant path) mutates that in place with no
state$/channels$ emission, so a $derived consumer never re-read it. Added a
fourth tick source subscribed through the client's directInviteWatcher$ ->
invites$ (the same observable the client's own onDirectInvite handler reacts
to synchronously before calling receiveChannelKeys), with a regression test
that reproduces the staleness through Svelte's $derived caching.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task 8 review fixes:
- PrivateChannelsView's whole template is now wrapped in
  {#if concord.enabled} — ?view=channels was reachable by direct URL with
  CONCORD_ENABLED=false and rendered the rail/founding pane anyway. Tab-level
  gating is unchanged.
- useConcordCommunity now returns signerHasNip44, derived from
  getConcordState().client?.signer?.nip44 (rune-tracked: state is a
  reassigned $state.raw). The template previously called the raw
  signerHasNip44() helper, which reads a plain module variable — no rune
  dependency, evaluated once at mount, so a tab mounted before the async
  client setup finished never showed the invites button.

Extended the hook test file with reactivity coverage for the new field
(false -> true -> false across client-ready/logout transitions, plus a
signer-without-nip44 case), restructured onto a vi.hoisted mutable-holder
mock so tests share one module instance without vi.resetModules().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Banned members were still re-receiving fresh channel keys on later
rotations: ChannelMembersModal built its roster from observed rumor
authors with no banlist subtraction, and moderation.js's
kickFromChannel/banFromChannel passed that roster straight through as
`keep` — rotateChannel would gift-wrap the new key to everyone in
keep minus exclude, banlist or not.

- moderation.js: channelMemberList() now subtracts an optional
  `banned` Set/array (self is never subtracted — display honesty).
  kickFromChannel/banFromChannel also defensively re-filter `keep`
  against `banned` themselves, so no future caller can regress this
  by forgetting to pre-filter the roster it passes in.
- ChannelMembersModal.svelte: bridges community.banlist$ via
  useObservable and threads it into channelMemberList and both
  moderation calls. Banned members now disappear from the roster
  (spec §3.3: guestbook ∪ observed − banlist).
- Tests added first (TDD): banlist subtraction (array + Set input,
  self-exemption, backward-compatible default), and defensive
  keep-filtering in kick/ban even when currentMembers still lists a
  banned pubkey.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
initConcordService()'s combineLatest subscriber ran `teardown(); await
setup(account)` with no serialization: since the subscriber callback
is async and RxJS never waits for it before delivering the next
emission, a slow setup() for a stale (logged-out or switched-away)
account could resume after a newer emission's teardown()+setup() and
either finish installing a client for the wrong account, or (via its
catch block) tear down state/currentClient that by then belonged to
the successor.

Adds a module-level `generation` counter, bumped synchronously in the
subscriber before teardown(). setup() captures it as `myGeneration`
and re-checks it after every await (dynamic imports, client.start()),
including at the top of the catch — a stale invocation only cleans up
its own locally-held client/subs and never touches the shared
currentClient/clientSubs/state, which may already belong to a newer
generation.

Added a unit test driving the exact interleaving with a mocked,
gate-controlled ConcordClient.start(): verified RED against the
pre-fix code (both the stale-rejection clobber and a stale
subscription-callback emission slipping through) and GREEN after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- index.js: drop the storage.js re-export (zero external consumers —
  grep confirms nothing outside src/lib/concord imports the barrel at
  all today; client.svelte.js reaches storage.js via its own dynamic
  import, tests import the submodule directly) and the dead
  signerHasNip44 raw-helper re-export. The barrel is now fully
  SSR-clean.
- Reconciled the documented convention with reality: components import
  Concord submodules directly (not the barrel) — that was never an
  "EXCEPTION" for SSR-rendered components, it's simply what every
  component already does. The barrel serves non-component/dynamic-
  import call sites (src/routes/+layout.svelte's initConcordService
  boot). Rewrote index.js's header comment and the CLAUDE.md Concord
  paragraph to say so, and corrected the same stale "barrel re-exports
  storage.js" claim in ChannelCreateWizard/ChannelInviteSheet/
  InviteInboxModal/invite/[naddr]/+layout.svelte, which described the
  barrel we just changed.
- Fixed stale "renders server-side" framing in PrivateChannelsView.svelte
  and ChannelChat.svelte: the /c tree is ssr=false (src/routes/c/+layout.js),
  so direct submodule imports there are defense-in-depth + consistency,
  not a load-bearing SSR requirement.
- .env.example: documented CONCORD_ENABLED/CONCORD_RELAYS next to the
  Membership block (previously undocumented).
- ContentNavSidebar.svelte / BottomTabBar.svelte: a strict-content
  community without a chat tab has chatIndex === -1, which spliced the
  channels tab before Home instead of before Settings. Insert at
  base.length - 1 in that case.
- ChannelChat.svelte: namespaced the key-backup bar's localStorage
  dismissal flag to `concord:keybar-dismissed:<pubkey>` so it doesn't
  leak across accounts on a shared browser profile; skips the bar
  entirely when no active user.

Verified: pnpm run build + grep .svelte-kit/output/server for
applesauce-concord/applesauce-core-concord/@noble/hashes — no matches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extract useConcordArea(getCommunityId) out of useConcordCommunity in
community.svelte.js, keyed on a raw Concord community id instead of a
Communikey 10222 pointer; useConcordCommunity becomes a thin wrapper
(parseConcordPointer + pointer field merge). All existing hook tests pass
unchanged.

Add pure helpers in unlinked-areas.js (linkedConcordIds,
unlinkedConcordAreas, concordAreaDisplayName) plus isConcordCommunityId in
pointer.js, TDD'd first — these compute which Concord memberships aren't
anchored to a followed Communikey community, for the upcoming sidebar
section and standalone /private/<id> route.
Add useUnlinkedConcordAreas() reactive hook (best-effort: reads whatever
kind 10222 events already sit in the EventStore, no new per-pubkey network
fetch) and wire a "Private Bereiche"/"Private areas" section into both
CommunitySidebar.svelte (the actually-rendered nav) and Sidebar.svelte (the
file named in the brief, currently dead code / unmounted anywhere).

Add the standalone /private/[id] route for opening an UNLINKED Concord
membership (joined via another client, or a bare invite with no Communikey
anchor here): flag-gated, id-validated, login-gated, then renders
PrivateChannelsView with communityId instead of communikeyEvent.
PrivateChannelsView's isOwner is split into isCommunikeyOwner (old formula,
only gates the founding pane) and isConcordOwner (material.owner-based, now
correctly gates moderation/dissolve/new-channel on both the linked and
standalone routes).

New i18n keys in de/en: concord_sidebar_private_areas, concord_unlinked_note,
concord_area_invalid_title/body; reuses concord_join_disabled_*/login_* for
the route's other gates.
Closes a live effect_update_depth_exceeded crash in useUnlinkedConcordAreas
(reading $state.raw inside the same effect that writes it, triggered by
eventStore.replaceable()'s synchronous replay), adds a bounded one-shot
addressLoader fetch per joined pubkey to shrink the linked/unlinked
duplicate-row window, gates the hook on the concord flag, extracts a
unit-tested privateAreaGate() for /private/[id]'s render cascade, adds
PrivateChannelsView owner-split coverage, and gives the standalone route
its own login copy instead of reusing the invitation string.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cross-client interop fixes found via live testing against Armada: our
client's relay set never included CORD-05's stock relays, so a list
created on Armada was invisible to us (Fix 1: merge Helpers.STOCK_RELAYS
into the configured relay set, pure mergeRelaySets() helper). With
autoUnlock:false there was also no way to ever decrypt a remote-only
Community/Invite List once synced (Fix 2: unlockConcordLists() +
useConcordListLocked() + a "Sync private areas" button in both
sidebars; hydration after unlock is automatic via the client's existing
watchLists()/reconcileCommunities() chain, verified against dist/).

Also (addendum, same interop bug): Armada's groups have only public
concord channels, which the private-only channel filter silently
dropped. deriveVisibleChannels() now includes public channels as
always-accessible (CORD-03: access comes from membership, not a
per-channel key), private channels unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ConcordClient crashed with "relay.isAuthenticated is not a function"
because the app's applesauce-relay@6.2.1 pool lacks the per-pubkey NIP-42
auth tracking that applesauce-concord's pinned fork's Relay class adds
(isAuthenticated(pubkeys), authenticatedPubkeys on status). This killed
community engine start for any relay gating reads/writes behind AUTH
(e.g. relay.ditto.pub), leaving cross-client communities stuck on "Noch
keine Kanäle".

adaptPoolForConcord() (src/lib/concord/pool-adapter.js) wraps the real
pool/relay with a transparent Proxy: everything not shimmed delegates
straight through, and if a future applesauce-relay build already has
isAuthenticated, wrapping is skipped entirely. isAuthenticated/authenticate
track auth state per (relay url, pubkey) keyed to the relay's current NIP-42
challenge string, so multiple stream-derived pubkeys can be authenticated
simultaneously on one connection without one invalidating another (the
single-slot `authenticatedAs` naive shim would cause an infinite AUTH
ping-pong via relay-auth.js's retry loop).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Unlinked Concord areas in CommunitySidebar/Sidebar now render an
Armada-style badge (name abbreviation on a deterministic DaisyUI-token
color, small lock glyph in the corner) instead of an identical lock-icon
circle for every area. Pure areaAbbreviation()/areaColorClass() helpers
live in unlinked-areas.js (TDD, node-env tests), rendered by a new shared
ConcordAreaBadge component.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ChannelMembersModal now displays community.members$ (community-wide,
banlist already folded in) instead of just authors observed in one
channel, split into owner/role-holder "leaders" (ordered by authority,
chip = role name or the new concord_role_owner label) and plain members
via the new pure memberSections() helper (roster.js, TDD).

Moderation semantics are unchanged: kickFromChannel/banFromChannel still
receive the CHANNEL-scoped keep-list from channelMemberList — a component
test locks in that a community member never observed in the channel is
excluded from the rotation's keep-list even though they now appear in the
displayed roster. concord_members_note updated to describe the roster as
community-wide.

New/changed message keys: concord_role_owner, concord_members_note.
Also adds concord_rail_channels for the next commit's header swap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PrivateChannelsView's rail now shows public + private channels together,
so "PRIVATE KANÄLE" was wrong — swapped to concord_rail_channels ("Channels"
/"Kanäle"), Beta badge unchanged. Channels sort alphabetically (locale-aware,
'de' compare) instead of insertion order. Rows drop the `btn` chrome for a
tighter list style, reusing the app's existing subtle active-nav treatment
(bg-primary/10 text-primary, as in BottomTabBar.svelte) instead of the
previous btn-active fill. `#`/`🔒` glyphs kept as-is; no data-testids touched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Locks in the avatar/header/name-link/timestamp/reply-button/bubble/
reply-preview/footer DOM shape so the upcoming shared chat/ component
extraction can be verified behavior-preserving.
Split the per-message rendering (avatar, header, name link, timestamp,
reply button, bubble, reply-preview, reactions footer) out of the
444-line Chat.svelte into src/lib/components/chat/ChatMessageRow.svelte
+ ChatMessageList.svelte, so ChannelChat.svelte (concord private
channels) can reuse the same visual rendering instead of its own
inferior hand-rolled markup. Chat.svelte keeps its data layer (loaders,
publish, emoji sets) and now renders through the shared components with
identical output — pinned by Chat.test.js and Chat.message-row.test.js.
ChannelChat.svelte previously hand-rolled its own message row and
looked visually inferior to the public community chat (Chat.svelte).
It now renders through the same src/lib/components/chat/ components:

- Reply-parent preview now shows the parent author's display name
  (previously content-only), matching the public chat.
- The reply trigger moves into the row header as a hover-reveal icon
  button (still calling the same local replyTo assignment), instead of
  a static footer icon; its title stays concord's translated
  m.concord_reply() string.
- Reaction badges + the static thumbs-up react button now render inside
  a `chat-footer` cell (DaisyUI-aligned) via the row's `reactions`
  snippet — concord's own aggregateChannelReactions() aggregation and
  community.react() publish path are untouched, only their container
  markup moved.
- Avatar/name now link to the pubkey's profile page, matching the
  public chat (previously concord's avatar/name were plain, unlinked).

Own (mine) messages now also get a header row (timestamp + reply
button), which concord previously omitted entirely for own messages —
an intentional parity fix, not a regression, since Chat.svelte always
renders the header regardless of ownership.

No change to concord's rumor timeline subscriptions, sendMessage/react
publish paths, or any data-testid used by e2e/concord-channels.test.js.
ReactionBar's addButtonOnHover reveal toggled `hidden` (display:none) to
`inline-flex` on :hover — a real layout swap, not just a visual one. On a
message with no reactions yet, that collapsed the whole reaction footer to
0x0 and expanded it back on hover, shifting every row below it in the
scrollable chat list. Confirmed live against the dev server: footer bbox
went from {0,0} to {34,24} on hover, and the row's own top position shifted
24px during a sustained hover (Playwright instrumentation, not reproduced
via jsdom since it's a real-browser CSS-layout effect).

Extracted the shared chip-rendering markup (ReactionBar + UrlReactionBar
duplicated it) into ReactionChips.svelte and switched the add-button reveal
to opacity (mirrors ChatMessageRow's reply button, which already reserves
space this way) so the footer's box is identical whether hovered or not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ChannelChat.svelte (concord private channels) previously hand-rolled a
badge-row + single hardcoded 👍 button instead of the public chat's
ReactionBar experience. It now uses the same ReactionChips component
(extracted in the prior commit): multi-emoji reactions, the emoji picker
(incl. user's custom emoji sets), and the identical hover-reveal affordance
— wired to concord's own data/publish path instead of eventStore/NIP-25.

- chat-helpers.js's aggregateChannelReactions now returns the same
  Map<emoji, summary> shape as the public chat's aggregateReactions()
  (count/userReacted/reactors/emojiUrl), not just a bare count, so both
  chats feed the identical ReactionChips props. userReactionEvent is always
  left null: ConcordCommunity has no retract/unreact method in the pinned
  applesauce-concord dist, so re-toggling an already-reacted emoji is a
  silent no-op rather than publishing a duplicate reaction rumor.
- The reply affordance was already unified via the shared ChatMessageRow
  (prior refactor); verified no leftover duplicate reply button in either
  chat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the boxed/centered card layout on /private/[id] with a full-bleed
flex shell (slim header + edge-to-edge rail/chat, matching the DM messages
page's h-full convention off root main's own height chain). Give the chat
pane an explicit bg-base-200 vs. the rail's bg-base-100 per the approved
prototype. Fix a missing min-h-0 on ChannelChat's message-scroll div that
let long chats grow past their allotted height instead of scrolling
internally (composer got pushed off-screen / scroll leaked to the page).
Also make the rail full-width in mobile single-column mode instead of a
fixed 288px with dead space beside it, matching the shell's fixed-288px
rail only once both panes show side by side.

Verified against the running dev server (real community + channel + 40
messages): tab context shows a single internal scrollbar with the composer
pinned and zero page-level scroll on desktop; standalone /private/[id] is
scroll-clean on both desktop and mobile. The /c tab's mobile drawer wrapper
(src/routes/c/+layout.svelte) has a separate pre-existing missing-min-h-0
gap affecting all tall community tabs (not just Concord) — left alone since
fixing it risks clipping unrelated pages that rely on today's page-level
scroll fallback; flagged for follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Community icons (CommunityMetadata.icon, a BlobPointer{url,key,nonce,hash})
are now decrypted client-side and rendered in the private-area badge (sidebar
+ CommunitySidebar) and the /private/[id] header, replacing the abbreviation
placeholder when available. Cipher confirmed from applesauce-concord source
(helpers/imeta.js, imeta.d.ts, client/admin.js), not guessed: AES-256-GCM
with a 32-byte key + 16-byte ("0xChat-compatible") nonce, hash covers the
DECRYPTED plaintext (NIP-92 ox), not the ciphertext at url.

- src/lib/concord/blob-media.js: pure decryptBlob() (Web Crypto only, zero
  package imports) + fetchDecryptedBlobUrl() (fetch + verify + object URL,
  module-level cache keyed by hash, warns once on failure).
- src/lib/concord/blob-media.svelte.js: useConcordAreaIcon() bridge hook.
- ConcordAreaBadge.svelte renders the decrypted icon with the abbreviation
  as fallback; corner lock glyph unchanged either way.
- unlinked-areas.js surfaces metadata.icon as iconPointer on UnlinkedArea.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- useConcordListLocked now maps to the derived BOOLEAN instead of the
  same cast reference, so $state.raw actually reassigns on .unlock()
  and $derived consumers (both sidebars) recompute instead of caching
  the pre-unlock value forever. RED test added mirroring the
  invite-tick $derived-consumer technique.
- CLAUDE.md's Concord section corrected: kind 1059 stream traffic stays
  on the community's material.relays, but 13302/13303 list sync and the
  direct-invite watcher use the merged set including CORD-05's public
  stock relays (post 6409473c) — a deliberate interop trade-off.
- Both sidebars now toast on unlockConcordLists() failure
  (concord_unlock_failed, de+en).
- aggregateChannelReactions skips falsy reaction.pubkey values before
  pushing into reactors (untrusted network input).
- Sidebar.svelte's unlock button now requires concordReady, aligning
  with CommunitySidebar's showUnlockAffordance gating.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extend the existing hasOwnBottomUI route gate (already used to hide the
FAB/Termi/scroll-to-top on the public ?view=chat tab) to also cover
?view=channels and the standalone /private/[id] Concord page, whose
in-flow composer rows were being overlapped by the fixed-position FAB
and Termi widget. Logic extracted into a pure, unit-tested helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Local-only per-device read markers in the per-account Concord IDB kv store,
central notifications service with two-tier (unread/mention) badges, reply +
@-picker mention producers, and foreground-only OS toasts with per-channel
levels. Armada-informed; see .superpowers/sdd/armada-notifications-research.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ChannelChat.svelte's per-channel notification-level menu (Task N9) added
four new m.concord_notif_level_* paraglide keys that ChannelChat.test.js's
hand-rolled messages mock never picked up, crashing all 7 tests in the file
on render. Add the missing keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stale-generation branch in client.svelte.js's setup() called the
module-singleton stopConcordNotifications() directly. Since there is one
notifications service per session (not one per setup() invocation), that
call stops WHATEVER service is currently running -- in the race where a
successor's own service already started by the time a stale invocation
resumes, it killed the successor's healthy service instead of doing
nothing. teardown() plus the successor's own start (which self-stops
first) already cover cleanup; delete the redundant call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The community layout mounts 2-3 responsive instances of PrivateChannelsView
simultaneously; hidden instances never receive row clicks, so their
component-local selectedChannelId stayed stuck at the default channels[0].
Any channels$ re-emission re-ran every instance's mirror-to-store effect,
letting a hidden instance overwrite the shared active-channel store back to
its stale default -- losing unread truth for the channel actually being
viewed and breaking its auto-mark-read.

Lift selection into a shared per-community map in active-channel.svelte.js
(selectConcordChannel/getSelectedConcordChannel, session-only) so every
mounted instance agrees; selectedChannelId becomes a $derived read of it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
maybeToast inspected only the single newest not-self rumor, so a burst
emission [newer non-mention, older mention] would drop the mention
entirely at toast level 'mentions'. Scan all rumors newer than the
previous fold instead, tracking the newest for createdAt/display-name and
whether ANY of them mentions the user separately.

Also delete notification-helpers.js's pruneMarkers -- superseded by
dropChannelMarker/dropCommunityMarkers in the service, unused anywhere
else (verified by grep).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The design doc claimed ChatMessageRow gets a bespoke lightweight token
renderer with self-highlighting. The shipped implementation instead reuses
the existing shared NostrContentRenderer -> NostrIdentifier ->
UserProfilePreview pipeline unchanged, producing a generic @displayname
chip with no self-highlight. Update the doc to match reality and note
self-highlight as possible future polish.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bff91f2c made Concord channel selection session-persisted per community
(active-channel.svelte.js), surviving PrivateChannelsView unmount/remount
on Home<->Channels tab switches. concord-notifications.test.js still
assumed a fresh Kanäle mount always falls back to channels[0]
(alphabetical) -- after creating Beta last, Beta stayed selected across
the owner's Home/Channels navigation, so it auto-marked-read on remount
before the test could observe its row's unread dot. The sticky-selection
design is intentional ("come back to the channel you were in"); the test
now explicitly reselects Alpha after minting Beta's invite so the later
assertions observe the intended precondition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Evaluation track alongside Concord (see plan). ts-mls + ContextVM behind
CORDN_GROUPS_ENABLED (default off: zero UI, zero network). Pure helpers
(envelope spec/02, sealed payload spec/03, config parse) unit-tested;
IndexedDB persistence namespaced cordn:<pubkey>; ESLint guard restricts
the MLS stack to $lib/cordn. Portions adapted from cordn-web (MIT).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Real-network Playwright test (skips unless CORDN_GROUPS_ENABLED=true):
two browser contexts run create → invite → welcome accept → bidirectional
kind-9 messages against the homelab coordinator over relay.contextvm.org.
Evaluation doc records the Cordn/Concord comparison and spike findings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CORDN_COORDINATOR_PUBKEY now accepts a comma-separated list (first entry
= default). One RPC per coordinator; key packages published to each;
welcomes fetched from all and tagged with their coordinator; groups carry
coordinatorPubkey (stored records migrate to the first entry). Coordinator
select on create, badges on groups/welcomes, per-coordinator failures
surface as a warning without blocking the others. Enables interop testing
against cordn.net's hosted coordinator alongside the homelab one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Decode the cordn_group_metadata extension (spec/01 TLS) for real group
names on join and admin lists; hide the invite form in admin-gated groups
for non-admins; 20s coordinator connect timeout; copy-pubkey button on
the probe page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implements the joining-device side of spec/applications/multi-device.md
(wire behavior matched to cordn-web): connection-string parse, kind-30078
tip fetch + owner-verified inner event, DEK-sealed Blossom documents,
§8 forward-only epoch reconciliation (seed/fast-forward/tombstone),
§9 seeding with cursor hand-off, §10 sibling-commit skip on shared-leaf
groups, §10.6 no-cursor-advance on ahead-of-epoch messages, periodic tip
re-poll. Synced groups are read+chat (commits stay in cordn.net — tip
write side deferred). Geräte-Sync card on /labs/cordn for pasting the
cordn.net connection code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Multi-device group documents (serialized MLS ClientState) exceed the old
65535-byte NIP-44 plaintext cap for larger groups; nostr-tools 2.23.9
(which cordn-web pins) raises the limits. 2.23.3 rejected the user's
131k group doc with 'invalid payload length'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Walk the group document prev chain for the oldest (gen-0) ClientState per
epoch, fetch the coordinator backlog across the covered cursor window, and
decrypt each half-open range with its epoch's state — recovering all
history that multi-device documents make reachable. Runs once per synced
group in the background after tip sync; messages older than the oldest
retained document remain unreachable by design (MLS forward secrecy).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Textarea composer with IME-safe keydown handling (composer.js, unit
tested); message bubbles render line breaks via whitespace-pre-wrap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rebuild /labs/cordn as a full-viewport rail+pane shell modeled on the
Concord channels view: 288px group rail (avatars, sync badges, member
counts), collapsible Geräte-Sync/Einladungen tools, chat pane using the
shared ChatMessageList/ChatMessageRow components (ported verbatim from
the Concord branch's extraction of community chat) with full profile
resolution via useProfileMap, date separators, auto-scroll, mobile
single-column toggle. E2e updated for the collapsed invitations section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/labs/cordn manages its own bottom composer — register it in the
layout's hasOwnBottomUI so the floating buttons no longer overlap the
send button.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Placement per design decision (claude.ai/design canvas): Cordn groups move
from /labs/cordn to /c/groups as an identity-scoped peer of Nachrichten.
The nav entry (desktop sidebar + mobile tab bar) appears only when the
deployment flag AND a new per-user settings toggle are on; /c/groups shows
an enable-in-settings explainer otherwise. Page strings converted to
paraglide (de/en). The /c layout double-mounts route children, so the
client moved into a singleton service (one MLS client per account,
destroyed on logout/switch); e2e selectors scope to visible instances.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Conflict resolution keeps both features side by side: config blocks
(cordnGroups + concord) in the store/API route, both ESLint import guards
(shared ignores for src/lib/cordn + src/lib/concord), both i18n key sets,
both COVERAGE rows, and the layout's bottom-UI check adopts Concord's
hasStaticOwnBottomUI helper extended with /c/groups. The shared
ChatMessageList/Row components merged identically (extracted on the
Concord branch, ported verbatim to the Cordn track).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implements the approved design (edufeed-comenius: "Concord Integration v2"):

- Attach/detach: link an EXISTING owned Concord area to a kind-10222
  community via the pointer tag (attach.js: buildPointerRemoval +
  attachConcordArea/detachConcordArea; pointer.js: withoutConcordPointer).
  Attach eligibility is owner-only (attachableConcordAreas +
  useAttachableConcordAreas) so moderation/invites keep working from the
  linked community; already-linked areas are shown disabled, dissolved ones
  excluded.
- Settings tab: owner-only "Privater Bereich" card (linked state with badge
  + open-channels + detach confirm; none state with create/attach actions)
  — the discoverable home for the flows; the Kanäle-tab founding pane stays
  as a shortcut and gains the attach secondary action (AreaAttachModal).
- Icon rail: aligned one-rail model — unlinked areas render at full
  community size with the corner-lock badge, no divider section; linked
  areas keep collapsing into their community entry.
- CreateCommunityModal: optional "Mit privatem Bereich" toggle; the area is
  minted with the HUMAN's client before any account switching (owner = human,
  spec §3.1) and the pointer rides along in the initial 10222 — with
  founding-marker idempotency against duplicate minting on retry.

TDD: 27 new unit tests (pointer removal, removal template, attach/detach
publish paths, attach eligibility); full concord suite 273 tests green,
svelte-check 0 errors, concord-channels e2e passes; all four flows also
verified live against relay.edufeed.org/concord.edufeed.org.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
64px rail minus a ~15px system scrollbar left 49px for 48px avatars with a
56px active ring — ring and avatars clipped. Per the "Rail Fix" design page
(edufeed-comenius project):

- --sidebar-icon-w 4rem → 4.75rem (76px): 56px ring + 10px clearance each side
- .scrollbar-none utility on the rail (Discord icon-rail pattern): no
  scrollbar eats into the fixed width; wheel/touch scrolling unaffected
- scroll affordance replacing the hidden scrollbar's signal: sticky edge
  fades + chevron, shown only while more content exists in that direction
  (scroll listener + ResizeObserver + list-length effect)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rail's lock-open button ("Sync private areas") confused users: an
unexplained icon that vanishes once clicked. It existed because the client
starts with autoUnlock: false, leaving the kind-13302 membership list
encrypted until a manual signer interaction.

The app already performs automatic NIP-44 decryption at startup for DMs
(dm-service), so the areas list now gets the same treatment: one unlock
attempt as soon as a LOCKED list cast appears. Users without any 13302
still see zero signer calls (nothing locked ever emits), preserving the
original no-signer-calls property for non-Concord users. The manual
affordance remains only as the fallback for a failed/rejected attempt or
signers without nip44.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three follow-ups from live testing:

- Rail tooltips: DaisyUI tooltips are CSS pseudo-elements and get clipped
  by the rail's scroll container (the truncated "Lock O…" box). Switched
  the icon rail to native title tooltips — browser layer, never clipped.
- Warm-cache rendering: decrypted rumors already persist per channel in
  the per-account IDB, and community state folds over those stores in the
  ConcordCommunity constructor — but the UI hid all of it behind the
  full-screen "Loading history" pane until the network epoch walk
  finished. The pane now shows only on a cold cache (no channels yet);
  warm loads render rail + chat instantly from disk with a small spinner
  in the rail header while sync completes.
- Surface alignment (design page "Channel Surfaces"): the channels view
  inverted the app convention (nav=beige, content=paper) — a stark paper
  rail between two beige nav zones, chat on beige. Rail and area header
  are now chrome (base-200), the chat pane is the paper content surface
  (base-100), and the composer is the beige pill — identical to the
  public community chat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Design for closing the direct-invite gap (reuse ContactSearchInput for
follows + npub), public/private channel toggle, empty-state, and rail
clarity/labeling. UI-only; no SDK/protocol change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds an isPrivate toggle to step 0 that flows into createChannel's
{private} option (default stays private), swaps step 1's bare member
button list for ContactSearchInput (with the member quick-list kept
below it), and shows a public-only note on step 2 when isPrivate is
false.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a small legend under the "Kanäle" rail header (# = open to everyone
in the area, 🔒 = only chosen members) and a matching title tooltip on
each channel row's icon, using the concord_legend_public/private message
keys from Task 1.
The send-invite action only lived inside the header's ⋯ overflow menu
(concord-menu-invite), where users missed it. Add a visible header
button (concord-header-invite) that calls openOverlay('invite'),
gated on !dissolved, reusing the existing concord_menu_invite message.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes creating an invite while a PUBLIC (#) channel is selected, which
threw "not a private channel we hold a key for" because grantChannelAccess
only handles private channels. directInviteToArea() builds a §1 bundle with
channels:[] (an AREA invite) and gift-wraps+publishes it directly, mirroring
grantChannelAccess minus the private key handoff. pickLatestChannelInvite()
gains an isPrivate=true param so public channels reuse the latest live area
invite instead of matching by channel id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Public (#) channels have no per-channel key, so grantChannelAccess threw
"not a private channel we hold a key for" for both the direct-invite and
link-mint paths. Route by channel.private: private keeps grantChannelAccess
/ channels:[channelId]; public uses directInviteToArea (dynamic import) /
channels:[] with a stable 'area' dedup key.
grantChannelAccess throws for public channels (no private key to hand
over). Route the invitee loop by isPrivate: private channels keep
grantChannelAccess, public channels go through directInviteToArea
(dynamic import, keeps applesauce-concord out of this component's
bundle).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The layout kept its own validContentTypes set that omitted 'channels', so the
nav (which reads the layout's selectedContentType) fell back to 'home' while
the content (page's selectedContentType) rendered channels. Dedupe both onto a
single shared VALID_CONTENT_VIEWS in contentTypes.js so they can't drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
useConcordArea now reactively reads roles$/grants$ off the active Concord
community and computes the active user's tier via memberTier() (Task 2),
exposing myTier plus five owner-inclusive capability booleans
(canManageChannels, canCreateInvite, canModerate, canManageRoles,
canPromoteAdmin) for later tasks to gate UI on. useConcordCommunity passes
these through unchanged since it spreads the base result.
ChannelMembersModal now offers per-member "Zum Admin machen" / "Zum
Moderator machen" / "Rolle entfernen" actions, gated by the new
canPromoteAdmin/canManageRoles props plus a canActOnTier outrank check
(owner acts on anyone-but-owner, admin only on moderator/roleless, nobody
re-roles the owner) — the SDK silently drops unauthorized grants, so the
UI has to gate proactively. Kick/ban are re-gated from isOwner-only to
canModerate (owner/admin/moderator). Tier chips now show localized
Admin/Moderator labels via roles.js's memberTier when a role matches a
preset bitmask.
Moderator kick/ban rekeys the channel (moderation.js → rotateChannel), which the
SDK gates on MANAGE_CHANNELS — without it a moderator's kick/ban would fold away.
Channel create/delete UI stays admin-only (tier-gated, not perm-gated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 5 of the Concord roles work: ChannelChat's invite (header + menu) and
delete-channel actions now gate on canCreateInvite/canManageChannels instead
of isOwner (dissolve stays owner-only); PrivateChannelsView passes the
capability flags from useConcordArea down to ChannelChat and
ChannelMembersModal, and re-gates the "+ Neuer Kanal" rail button on
canManageChannels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Regression from the P4 Termi invites hint: hintCopy.invites references these
keys; the TermiAssistant component test's partial messages mock lacked them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The moderator fixture hardcoded the pre-MANAGE_CHANNELS MOD_PERMS; import the
constants so the fixture can't drift from the real presets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Kick/ban buttons were gated only on canModerate && !self, with no
per-target outrank check (unlike role actions, which use
canActOnTier). A moderator could see and click kick/ban on the owner
or any admin: community.ban() has no client-side authority gate, so
the SDK fold would partially commit (poison members$/banlist) before
rotateChannel's own outrank check threw, surfacing a misleading
"failed" toast while the roster was already griefed.

Add canModerateTier (owner->anyone-but-owner; admin->{moderator,
roleless}; moderator->{roleless}), distinct from canActOnTier since a
moderator's kick/ban of a roleless member is legitimate (MOD_PERMS
includes MANAGE_CHANNELS) where canActOnTier would wrongly hide it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pure module (no package imports, SSR convention); parity with the pinned
applesauce-concord parseImeta is asserted by test, not assumed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Decrypt layer reuses blob-media's AES-256-GCM path (new
fetchDecryptedAttachmentUrl, cached by url, ox-verified when present,
unencrypted pass-through). MessageAttachments maps mime class to
img/video/audio/download chip with skeleton + unavailable fallbacks;
ChannelChat strips attachment URLs from the rendered content clone.
ChatMessageRow gains an optional attachments snippet (presentational,
non-breaking).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Armada-parity tag layout (buildV2CommentTags: K/E/P root inherited
verbatim on nested replies, k/e/p immediate parent, rumor ids).
ThreadPanel side view reuses ChatMessageRow + the attachment stack;
publishing goes through community.sendEvent so the CORD-03 binding and
sealing match every other rumor. Footer badge shows per-root reply
counts; hover affordance starts a new thread. replyToThread from the
pinned dist was deliberately NOT used — it pins the pointer kind to 11
(forum threads), but chat threads root on kind-9 messages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Poll rows join the kind-9 timeline; votes are e-referencing side events
tallied latest-per-pubkey (epoch-ms), endsAt cutoff, undeclared options
dropped — semantics matched to Armada's shared polls module so both
clients fold identical counts (implementation our own). Single-choice
rows vote on click, multi-choice via checkboxes + Vote; publishes
through community.sendEvent. Mutation-checked: removing the endsAt
filter fails the tally test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Events are rumors, so identity is (kind, author, d) with newest
created_at winning and RSVPs e-reference the rumor id (no a-coordinate)
— semantics matched to Armada's shared calendar module. Upcoming events
surface in a collapsible bar above the chat, never as timeline rows;
one-click RSVP with latest-per-member tally publishes via
community.sendEvent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CORD.md rule enforced locally: sha256(preimage) must equal the bolt11
payment_hash AND the amount tag must equal the invoice amount —
unverified receipts never enter tallies. Dedupe is by payment proof
(hash/txid), never rumor id, so relay echoes cannot double-count.
bolt11 decoding via light-bolt11-decoder (promoted from transitive to
direct dep; SSR-safe pure JS); hashing via WebCrypto like blob-media.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New /groups lane: kind-9 h-tagged chat (the shape the public community
chat already speaks) read from and published to the GROUP'S relay only,
relay-authored metadata/roster (39000/39002), 9021/9022 join/leave,
one-shot NIP-42 authenticate-and-retry on auth-required, and a
kind-10009 GROUPS-list mirror (public entries preserved verbatim incl.
the NIP-51 hidden content) so joined groups roam. Protocol constants,
pointer codec, and list parsing come from applesauce-common's NIP-29
helpers; UI is the shared ChatMessageList/ChatMessageRow/ReactionChips
stack (DRY — zero protocol code in components beyond wiring).

Component test drives a fake relay through the REAL applesauce
EventStore + TimelineModel with really-signed fixtures (the store
hash-checks events; fakes vanish silently and made assertions vacuous).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TestOER blocker at 07753b2f: PollMessage seeded the multi-choice
selection from tally.myVote once at mount, but kind-1018 votes hydrate
after the message renders — so a returning voter saw highlighted rows
with unchecked boxes, and adding one option submitted ONLY that option
(NIP-88 latest-per-pubkey then replaced the vote: silent data loss).

The re-seed $effect keys on vote CONTENT, never object identity —
ChannelChat rebuilds the tally object every render, and an
equal-content rebuild must not clobber in-flight user toggles (the
second test pins exactly that against the naive-$effect fix).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TestOER finding on :5180: parseGroupInput('not a pointer') is null in
Node but {relay: 'wss://not%20a%20pointer/', id: '_'} in Chrome, whose
URL parser percent-encodes forbidden host bytes instead of throwing.
Garbage input therefore skipped the groups_invalid_pointer branch and
rendered a full chat shell for a nonexistent relay.

isValidRelayUrl requires a DNS-shaped hostname (or bracketed IPv6)
explicitly instead of trusting new URL to throw, and is tested against
the verbatim string Chrome fabricates — the node-pinned environment
cannot reproduce the lenient parse itself. Both call sites (input box
and /groups/[pointer] route) funnel through parseGroupInput, so a
pasted garbage URL now hits the existing invalid-pointer state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The thread feature added a 'Reply in thread' button on every message row;
getByTitle('Reply') substring-matches both and fails strict mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The page held the kind-10009 timeline in deep-proxying $state, but
getPublicGroups() memoizes by writing a Symbol-keyed cache onto the
event object. Inside the `groups` $derived that write hits a reactive
proxy and Svelte throws state_unsafe_mutation, killing the whole route
render — a user WITH groups saw a broken page while a user with none
saw a healthy empty state, so every test and probe (all group-less)
stayed green. Found by loading /groups in Chrome with a read-only
login as a pubkey that has a real Armada-written 10009.

Hold the events in $state.raw: they are external store objects, and
proxying them also breaks applesauce's identity/cache contracts.
TimelineModel emits fresh arrays, so reassignment stays reactive.

Test renders the page through the real EventStore/TimelineModel path
with a really-signed 10009 fixture; it fails at the parent commit with
the exact production stack (state_unsafe_mutation at +page.svelte:45).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adopted from TestOER's verification probe: the crash-fix test renders only
the first emission, so it stays green if later 10009 updates stop landing —
exactly the regression raw state risks. This drives a second, newer list
through the real EventStore and asserts the UI switches group sets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Merge branch 'feat/concord-render-gaps' into dev
Some checks failed
Build and Push Docker Image / build (push) Failing after 5m31s
d96d19051a
Approved by laoc 2026-08-07. Unblocks the parity branch tower, which had
35 commits stacked on this branch while dev moved 127 commits ahead.

Four textual conflicts, all resolved as unions because both sides were
additive:

  modal.svelte.js        dev's 'membershipApply' + concord's 'concordInvites'
  ModalManager.svelte    both imports, both render branches kept
  TermiChatWindow.svelte dev's copyFor() abstraction kept; it already falls
                         through to hintCopy, so it handles concord's new
                         'invites' hint at runtime. Its cast omitted that
                         member, so the cast was widened to match.
  e2e/COVERAGE.md        row union (dev +2, concord +3), total recomputed
                         from the table: 317

The damage was NOT in any of those. Git merged HomeInboxCard cleanly --
different files, different regions -- while dev added an
m.inbox_filter_replies() call and concord's HomeInboxCard.test.js mocks
messages by ENUMERATED keys. Three tests failed at runtime on a clean
merge. Found by isolating the file and diffing the surface: the component
uses 15 message keys, the mock enumerated 14.

Verified on the merged tree:
  vitest  547/547 files, 5828/5828 tests, 0 FAIL
  check   0 errors
  exit 1 is 6 unhandled teardown leaks, both inherited, neither created
  here: GlobalFAB (dev side, documented) and
  concord-community-invite-tick (concord side, reproduces in isolation
  at 3/3 passed + 1 error).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test(inbox): add the message key the merge left the mock short of
Some checks failed
Build and Push Docker Image / build (push) Failing after 5m43s
2e997b40a1
The concord merge (d96d1905) is textually clean here but semantically
short: dev added an m.inbox_filter_replies() call to HomeInboxCard.svelte
(95db6336) while concord's HomeInboxCard.test.js mocks
$lib/paraglide/messages.js by ENUMERATED keys. vi.mock factories are
copied by own keys, so a key the factory omits does not fall through to
the real module -- it throws on access.

Git flagged nothing: the two sides touched different files. The component
uses 15 message keys, the mock enumerated 14.

All 3 tests in the file failed on the merge commit, deterministically
(44ms, not a timeout). Green after: 3/3.

Verified on this commit:
  HomeInboxCard.test.js            3/3 pass
  inbox-{service,prefetch,read-tracking} 28/28 pass in isolation
  pnpm check                       0 errors, 4 pre-existing warnings

The 3 inbox-* failures in the full-suite run are the known load flakes on
this repo (30s+ hook timeouts under parallel load, green in isolation);
they predate this merge and are not addressed here.
docs: document nostr/ngit as primary git remote
Some checks failed
Build and Push Docker Image / build (push) Failing after 5m40s
958949ef5b
Origin is now nostr via ngit, with Forgejo/GitHub configured as
auto-push mirrors on origin. Records the signer-approval hang,
3x pre-push hook, and dual-maintainer-bucket gotchas so future
sessions don't mistake them for failures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
laoc added 51 commits 2026-08-20 21:05:31 +00:00
Spam DMs (e.g. legacy kind-4 LinkBio blasts on public relays) previously
landed straight in the main conversation list and counted toward the
unread badge. Display-side trust classification now splits the list:

- Known senders (followed, replied-to, deployment-trusted via
  DM_TRUSTED_SENDERS, or self) stay in the main list; strangers move to
  a collapsed "Requests" section that never fires the unread badge.
  Replying to a request auto-promotes it (outbound-message signal from
  both wrapped rumors and legacy kind-4s).
- New mute-list store (kind 10000) with a settle guard against the
  local-miss overwrite race; "Block sender" on request rows and in the
  thread header goes through applesauce MuteUser/UnmuteUser, so the
  list syncs across clients. Muted senders vanish from DMs and inbox
  notifications alike (inbox queries stay ungated, issue #43).

Relay subscriptions are untouched — the wide gift-wrap/kind-4 listening
net stays, so no message is lost, only re-shelved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both platform accounts DM new users at signup; without the default their
welcome messages would land in the requests folder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mention-spam campaigns rotate pubkeys (the Damus Airdrop bot used four in
five days), so author muting alone is whack-a-mole. Two additions:

- Every inbox notification row gets a 'Block sender' action (kind 10000
  MuteUser via the existing mute store); the inbox filter reacts and all
  rows from that author disappear at once.
- Muted words: the mute store now exposes the list's NIP-51 'word'
  entries (lowercased) and the inbox drops notifications whose content
  matches one, case-insensitive substring — a single entry like 'damus
  airdrop' silences the campaign regardless of sender. Managed in the
  My Stuff → Lists mute card (add/remove; wisp honors the same list),
  which now renders even before a kind 10000 exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- fetchAndVerifyXdc now throws XdcIntegrityError when expectedSha256 is missing/empty
- unzipXdc now throws on path collisions after normalization (malformed/malicious archives)
- Added 3 new tests covering both security fixes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

buildLicenseTemplate accepts optional alt/image, emitting NIP-94/NIP-DC
alt + image tags after credit. LicenseModal gains an attestExtras prop
spread into the attestation input on save. publishLicenseAttestation
routes application/x-webxdc attestations to the educational (AMB)
relays via additionalRelays so NIP-DC discovery pickers find them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds InteractivePackageInput.svelte, the step-2 upload field for the
interactive resource-form variant. Normalizes .h5p/.xdc/.html uploads
into a .xdc, defers the Blossom upload to LicenseModal's beforeAttest
(mirrors LicensedFileInput), uploads the extracted icon alongside, and
lets the modal publish one kind-1063 that doubles as license
attestation and NIP-DC discovery event.

Also extends vitest.setup.js's existing Blob/File body-reading
polyfill to arrayBuffer() (previously only text() was covered) — jsdom
leaves it unimplemented, and this component hashes/zips uploaded
bytes via file.arrayBuffer().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The LicenseModal cancel path only closed the modal (modalOpen = false)
and left sizeWarning, pending, and its pendingName/pendingFileName/
pendingHash/pendingSize/existingLicense/iconUrl/pendingBytes mirrors
untouched. Repro: pick an oversized package (sizeWarning shows, modal
opens), click Cancel — the modal closes and the file input re-renders,
but the '>50 MB' warning stays visible with no package selected.

Adds a dedicated onLicenseCancelled() handler that clears all of the
above (plus the error banner, for the same reason) and wires it to
oncancel in place of the inline closure.

Also fixes an unrelated sharp edge the new test surfaced in the test
file's own pick() helper: Object.defineProperty(input, 'files', ...)
without configurable:true throws on a second call against the same
(Svelte-reused) <input> node — needed once a single test exercises
pick() twice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
svelte-check: 83 errors (82 in the new src/lib/webxdc/* module, 1 in
AMBResourceCard.svelte's isInteractive derived) down to 0. All fixes
are JSDoc annotations / type narrowing, no behavior changes:

- xdc-archive.js: JSDoc for wrapHtml/fetchAndVerifyXdc, BufferSource
  cast for crypto.subtle.digest
- webxdc-host.js: JSDoc for listenerSerial/realtimeOff/post/method/params
- sandbox-protocol.js: MIME as Record<string,string>, JSDoc for
  injectScriptTag
- SandboxFrame.svelte / WebxdcPlayer.svelte: JSDoc for post/host/stopHost
- local-sync.js: widen AppSync meta fields to accept falsy-but-valid
  non-string values (matches existing test coverage)
- webxdc-host.test.js / sandbox-protocol.test.js: typed test fixtures

eslint: static/h5p-standalone/** (vendored h5p-standalone dist output,
scripts/vendor-h5p-standalone.mjs) was tripping 224 errors as
third-party minified code got linted as our own. Added it to
eslint.config.js ignores alongside the existing paraglide exclusion.

Verified pre-existing, out-of-scope failures left untouched:
InboxItem.test.js / InboxItemReplyLabel.test.js (missing
dm_block_sender mock, fails identically on dev@623fba51),
HomeInboxCard.test.js dupe-key eslint error, e2e/COVERAGE.md and
TermiChatWindow.svelte prettier warnings.
Extend the kind-1063 table row to note its dual role as NIP-DC webxdc
discovery, and add a "## Interactive Resources (webxdc)" section
(placed after Image License Attestation, before Communikey Protocol)
covering the src/lib/webxdc/ module, SANDBOX_DOMAIN config, the
`interactive` form variant, and the dual-purpose 1063 publish/routing
flow, with a pointer to the design spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
zipXdc used fflate's zipSync with default mtime, which stamps the
current time into each zip entry's header. Wrapping identical bytes
twice produced two different SHA-256 hashes, so findExistingLicense
never matched and duplicate 1063 attestations were published.

Pin every entry's mtime to the earliest date the ZIP/DOS timestamp
format supports (1980-01-01) so identical input always produces an
identical archive.

Test: zipXdc is deterministic for identical input, verified via
vi.setSystemTime across two distant instants (RED without the fix).
The wizard's draft store persists formData (encodings + identifier),
but on restore the interactive variant's local `interactivePackage`
state stayed null: step 2 rendered an empty file input, and the
mapping $effect's else-branch then wiped the just-restored
formData.encodings because it treated "interactivePackage is falsy"
as "user cleared it" — true both on fresh mount and right after a
draft restore that hasn't seeded it yet.

Fix:
- New pure helper `seedInteractivePackageFromEncodings()`
  (interactiveResource.js) builds an InteractivePackage from the
  formData-shaped (type-keyed) encodings array; unit tested.
- Draft restore calls it to seed `interactivePackage` for
  variantId === 'interactive'.
- The mapping $effect now only clears formData.encodings on an
  explicit non-null -> null transition (tracked via a plain
  `hasProjectedInteractivePackage` flag, not $state — bookkeeping
  only), not merely because interactivePackage is still null at
  mount/restore time. This is the "Replace package" button's
  transition; fresh mount and not-yet-rehydrated drafts no longer
  trip it.
- InteractivePackageInput hides the dead "Preview" button when a
  restored/edit `value` has no local `pendingBytes` to preview
  (name/size still render).

Edit-mode's own interactivePackage rehydration in prefillEditData()
is left as-is: editResource.encodings is mimeType-keyed (from
getAMBEncodings), a different shape than the draft/formData
type-keyed encodings the new helper expects, so reusing it there
would need a shim for no simplification.

Tests: interactiveResource.test.js (seedInteractivePackageFromEncodings,
RED without the fix), InteractivePackageInput.test.js (restored-value
card hides Preview, still shows Replace).
Editing an interactive-variant resource re-seeded interactivePackage
with licenseEvent: null (the kind-1063 attestation lives on the
network, keyed by SHA-256, not in the kind-30142 edit event), which
validateWizardStep's interactive step-2 branch and the step-5 license
gate both then rejected as "needs file" / "license missing" — edit
mode could never reach publish.

- validateWizardStep.js: interactive step 2 and the step-5 license gate
  now exempt edit mode (mirrors the existing no-URL step-2 exemption):
  the d-tag/package is immutable once published, so the original
  attestation already covers it.
- ResourceFormWizard.svelte: added a rehydration effect for the
  interactive package's license event, mirroring the existing
  editImageHash/useLicenseForHash pattern — subscribes on the
  package's sha256 and fills interactivePackage.licenseEvent (which
  the existing mapping effect projects onto formData.encodings[0])
  once the 1063 is found. Defense in depth on top of the validation
  exemption, and keeps the step-5 gate/UI reflecting reality instead
  of trusting an unverified null indefinitely.

Tests: interactiveVariant.test.js gains edit-mode cases for step 2 and
step 5 (pass with a licenseEvent-less x-webxdc encoding in edit mode;
still fail with no encoding at all / a non-webxdc encoding). A new
isolated-effect test (ResourceFormWizard.interactive-license-rehydrate)
locks in the rehydration effect's contract the same way the existing
edit-prefill regression test does, since mounting the full wizard in
jsdom is impractical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SandboxFrame passes url.pathname (percent-encoded) straight into
buildFetchResponse, which matched it against decoded zip-entry names —
any packaged asset with a space, umlaut, or other non-ASCII character
in its filename 404'd. buildFetchResponse now decodeURIComponent()s
the pathname before matching, falling through to the normal 404 path
(instead of throwing) when the escape sequence is malformed.

Tests: a file map entry with a space+umlaut served at its
percent-encoded path now returns 200; a malformed percent-escape
returns 404 rather than throwing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
wrapHtml and wrapH5p produced archives with no icon entry (spec
requirement). Added xdc-archive.js's ensureDefaultIcon(files, bytes) —
a pure, network-free helper that injects icon.png only when the
package shipped neither icon.png nor icon.jpg — and call it from
InteractivePackageInput's onFileChange after the wrap/unzip branching,
uniformly across all three input paths (raw .xdc passthrough
included), before extractXdcMeta runs.

Placement: the fetch itself (`/icon-192x192.png`, the app's existing
static icon) lives in the component, not inside the wrap modules.
wrapHtml's module docstring already promises "no Svelte, no Nostr...
safe in node and browser" — network-free — so injecting a fetch there
would break that contract. wrapH5p already fetches (unavoidably, for
player runtime assets), but that's a different concern from a generic
default icon; duplicating icon-fetch logic into three separate wrap
functions would be worse than one fetch in the component shared by all
of them. This also means the existing icon-extraction/upload path
(extractXdcMeta → pending.iconBytes → beforeAttest icon upload →
attestExtras.image) picks the default up automatically, no further
changes needed. Best-effort: a failed fetch is swallowed, never blocks
publishing.

Tests: ensureDefaultIcon unit tests (injects when missing, leaves an
existing icon.png/icon.jpg untouched) in xdc-archive.test.js; a
wrapH5p + ensureDefaultIcon + extractXdcMeta combo test in
h5p-wrap.test.js; a component-level test (mocked LicenseModal +
fetch, in the LicensedFileInput.test.js style) confirming the default
icon reaches the Blossom upload via beforeAttest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- generateBridgeScript's message listener now checks
  `event.source !== window.parent` as its first statement, rejecting
  any postMessage not sourced from the host frame before it reaches
  the jsonrpc/id/method dispatch. Test asserts both the check's
  presence and that it's the first statement in the handler body.
- local-sync.js: documented AppSync's getUpdates() append-only
  contract in the typedef JSDoc — createWebxdcHost derives each
  update's serial from array index, so reordering (e.g. re-sorting by
  created_at after a late backfill) would reshuffle serials and drop
  mid-inserted updates for listeners. Comment only, no code change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Amend the interactive-resources design doc with three controller
rulings/decisions made during implementation:

- Section 3: the .h5p wrapper's pre-publish preview launch is optional,
  not mandatory — relaxed during implementation (controller ruling):
  the license disclosure is the publish gate; forcing a launch was
  judged educator-hostile. Also touched the matching "safety net" line
  under Open questions/risks for consistency.
- Section 1: the hcrt-application default learningResourceType is
  marked deferred — the wizard currently requires a manual pick.
- Open questions/risks: added a "Popup exfiltration (accepted)" entry —
  allow-popups-to-escape-sandbox + a user gesture lets a package
  exfiltrate its own state via window.open URL params despite the
  no-network CSP; same trade-off Armada makes, the sandbox protects the
  host app, not the package's own data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
h5p-standalone's default embedType: 'iframe' creates an about:blank inner
iframe whose subresource requests bypass iframe.diy's service worker in
Chrome, so every H5P library script gets the host's HTML bootstrap page
instead of the sandboxed app ("Uncaught SyntaxError: Unexpected token '<'")
and the player never boots. embedType: 'div' renders directly into the
container div, keeping all requests on the SW-controlled app document.
Verified against real iframe.diy with the Multiple Choice sample.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
wrapH5p now returns licenseUrl/credit derived from h5p.json's license (+
licenseVersion) and authors, via the new pure h5pLicenseToUrl(code, version)
helper (six CC BY* variants, CC0 1.0, PD/CC PDM → URLs; U/C/GNU GPL/ODC PDDL/
unknown → null). LicenseModal grows additive initialLicense/initialCredit
props that seed the create-license form on open (existing callers are
unaffected — both default to null). InteractivePackageInput wires wrapH5p's
licenseUrl/credit through pending state into those props for the h5p upload
path; every other input path (raw .xdc, wrapped .html) passes null and keeps
the modal's old hardcoded defaults. Educators can still edit every field —
this is prefill only.

Cross-checked against licenseOptions.js: getLicenseOptions() synthesizes an
<option> for any URL it's handed, so by-nd/by-nc-nd/CC-PDM (not in the
static option list) still preselect correctly in the <select>.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- wrapH5p() now also returns source (h5p.json's optional source URL,
  null when absent/empty)
- LicenseModal gains additive initialTitle/initialSource props, seeded
  the same way as the existing initialLicense/initialCredit (create-view
  open only; no effect on existing callers)
- InteractivePackageInput passes initialTitle (package name, all input
  types) and initialSource (h5p source, null otherwise) to the modal

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Swap the uploaded-files row's "Anzeigen" link from GlobeIcon (wrong
semantic — looks like an external/browser action) to the existing
EyeIcon, which already carries the correct view-action meaning
elsewhere in the icon system (visibility toggles). GlobeIcon had no
other callers in this file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- WebxdcPlayer exposes launch() as an instance method (launchApp())
  so it can be triggered from elsewhere on the page via bind:this
- AMBResourceView binds the player instance + a container ref; the
  uploaded-files row's webxdc entry now renders a launch button
  (m.webxdc_launch()) instead of a link that downloaded the raw .xdc
  ZIP, and scrolls the player card into view on click. Non-webxdc
  rows and "Herunterladen" are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
isInteractiveCandidate + prepareInteractivePackage, lifted from
InteractivePackageInput so the generic upload flow can reuse it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgNcULp2a5Wdc6iFkr34xL
Opt-in detectInteractive prop on LicensedFileInput: .h5p/.xdc run
through the webxdc pipeline automatically, .html asks first; license
modal gets h5p prefill + NIP-DC attest extras, beforeAttest ships the
app icon alongside the package.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgNcULp2a5Wdc6iFkr34xL
Remove the wizard's interactive-only branches (step-2 package input,
mapping/rehydrate effects, validation special-case); both
LicensedFileInput usages get detectInteractive and the no-URL accept
list now includes .h5p/.xdc/.html. Resource title prefills from the
package's license title while untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgNcULp2a5Wdc6iFkr34xL
Packages enter through every variant's upload step now; events labeled
'interactive' fall back to the amb form on edit (existing behavior of
resolveVariantIdFromEvent for unknown variants).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgNcULp2a5Wdc6iFkr34xL
Predicate-refined content type: kind-30142 events carrying the NIP-DC
m=application/x-webxdc marker group under a new 'interactive' shelf
(puzzle icon, --ct-interactive accent) instead of the learning shelf;
its create CTA routes into the learning flow via ctaKey.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgNcULp2a5Wdc6iFkr34xL
InboxItem gained m.dm_block_sender & friends in 623fba51 but the two
component-test mocks were never extended, failing the suite on dev.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgNcULp2a5Wdc6iFkr34xL
Servers like haven report application/zip for the wrapped package; the
slot update trusted blob.type, so the resource lost its m/x tags,
player, and Interactive shelf. Force application/x-webxdc for
interactive slots.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgNcULp2a5Wdc6iFkr34xL
Merge webxdc-normal-upload: interactive packages in the normal upload flow
Some checks failed
Build and Push Docker Image / build (push) Failing after 5m32s
969682eab3
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgNcULp2a5Wdc6iFkr34xL
Some checks failed
Build and Push Docker Image / build (push) Failing after 5m32s
This pull request is marked as a work in progress.
This branch is out-of-date with the base branch
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin dev:dev
git switch dev

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff dev
git switch dev
git rebase main
git switch main
git merge --ff-only dev
git switch dev
git rebase main
git switch main
git merge --no-ff dev
git switch main
git merge --squash dev
git switch main
git merge --ff-only dev
git switch main
git merge dev
git push origin main
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
edufeed/edufeed-app!56
No description provided.