WIP: dev -> main release gate — config/env that must be decided and set before deploy #56
No reviewers
Labels
No labels
bug
discussion
enhancement
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
edufeed/edufeed-app!56
Loading…
Reference in a new issue
No description provided.
Delete branch "dev"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.
devis 97 commits / 169 files ahead ofmain(14428 +,2139 -).mainis atfaf6d14c,devat09842a48.Two things that are easy to get wrong
1. Merging this does not deploy anything.
.forgejo/workflows/docker-build.ymlbuilds, smoke-tests and pushes an image —:latestformain,:devfordev. There is no deploy step. Rolling it needsansible-playbook playbooks/deploy_edufeed_app.ymlin the homelab repo.2. Almost every new feature is off by default. The toggles in
src/routes/api/config/+server.jsareparseBool(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? Defaultfalse, 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_URLdefaults tohttps://auth.njump.meandPOMEGRANATE_OPERATOR_URLSto 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 defaultfalseand set nowhere. Cheap and low-risk, but it is a product choice.MEMBERSHIP_ENABLED— the@edufeed.orghandle application flow. Defaultfalse. 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_ENABLEDwithout 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 template30168:<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/nip05returns 503 and approvals fail at the last step, after the admin has already clicked.Vocabularies — 11
SCHEME_NADDR_*missing on prodProd has 14 of the 25 the dev instance sets. Missing:
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_AMBRESOURCE_FORM_TEMPLATE_NADDR_EKWThese are the only two env vars this branch newly reads that
maindoes 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 toAPP_NAME, thenEdufeed.BODY_SIZE_LIMIT— not read by app source; consumed by@sveltejs/adapter-nodeat 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 forconcordacross the whole repo at09842a48returns nothing. Dead config as far as this branch is concerned. Confirm before carrying it to prod.Known open problems that are not release blockers
pnpm testondevis not a trustworthy signal (random teardown error with all tests passing, plus two tests that need.envexported). Worth knowing before anyone reads CI on this PR as meaningful.Verification state of the branch tip
At
09842a48, with.envexported: 5149 tests passed / 5149, 468 files,pnpm lintclean,pnpm check0 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.ymland the read sites insrc/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 worthdocker exec … envonedufeed-appbefore relying on it.Requested by @laoc_buzz in the membership thread in #edufeed-app.
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).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>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>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 at00ad97bbproduces 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 trap818841adfixed 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 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.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>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>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: #62Task 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>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>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>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>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>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.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.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.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>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>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>View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.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.