feat: NIP-101 forms + AMB-serializer convergence (conformant Konfi ext namespace) #50
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!50
Loading…
Reference in a new issue
No description provided.
Delete branch "feature/nostr-metadata-forms"
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?
Lands the NIP-101 forms work and the AMB-serializer convergence (5 plans, 71 commits, rebased clean onto dev).
What's in here
amb-nostr-converter:formValuesToAmbJson → ambToNostrand edit-reads vianostrToAmb → ambJsonToFormValues; retired in-appamb-emitters.js/form-to-amb.js.amb.ext(fixes the live-preview omission).ext:grammar): Konfi moved off the illegalext:ekw:konfi:<slug>:id(5-segment, ambiguously parsed, corrupted relay data) to the conformantext:org.edufeed.ekw.konfi:*namespace. Non-konfi EKW stays underekw(already legal).Verification
check0 errors,lintclean; amb-basic E2E ran for real.ext:org.edufeed.ekw.konfi:*present, zeroext:ekw:konfi:*; deleted afterward (kind-5 confirmed on relays, 30142 gone).Follow-ups (not in this PR)
scripts/migrate-konfi-namespace.mjs, dry-run verified): 11 existingext:ekw:konfi:*events across 3 author pubkeys. Publish needs each author's signing key and should run after this deploys (and after NIP-BOSS's relaynostr_amb.gofix + converter write/parse guards land).:type Conceptparity).🤖 Generated with Claude Code
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>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.Mixed
ext:facets — concepts and free-text scalars both survive now (3ee3a208)Follow-up to the grammar fix in this PR.
parseExtensionTagslocked a facet to a singlekindby whichever tag arrived first and skipped every later tag of the other kind, so a facet carrying both Concepts and free-text scalars lost one half.Not hypothetical:
formDataToAmbExt.buildKonfiFacetsemits exactly that for anallowCustomvocab field — Concepts as:id/:prefLabel:*/:typeruns, then the custom string as a bareext:<ns>:<facet>tag. Concepts are always emitted first, so the loss was deterministic, not order-dependent in practice: pick a vocabulary term and type a custom value, and the custom value vanished from the resource view.Reproduced against the pre-fix parser:
The relay already gets this right
nostrlib/eventstore/typesense30142/nostr_amb.goaccumulates concept instances and scalars independently and concatenates them. Probed on the same input:So the value is on the relay and correctly indexed in Typesense — only the app dropped it. This commit ports the relay's shape rather than inventing a second one, so the two readers cannot drift apart again (which is the failure this whole workstream exists to fix).
The change
itemsis renamed rather than widened on purpose — every consumer breaks visibly instead of silently keeping the old half-a-facet behaviour.buildExtensionCardsrenders a mixed facet through the scalar stack, concept labels first.MetadataCardGridrendersscalarsin place ofvalue, so the two can't share a card; the stacked list shows everything without touching the component.booleanFacetValuenow requireskind === 'scalar'. A lone'true'scalar next to concepts is not a flag, and treating it as one binned the concepts with it.summarizeExtensionFacetssurfaces both halves under kind'mixed'.Verification
Corpus check (31
#l=ekwevents,wss://amb-relay.edufeed.org): zero mixed facets today, and zero after simulatingmigrate-konfi-namespace.mjsend to end.zeitstrukturis the onlyallowCustomfield, and the one live custom value (f8fc9ce8…,"2 x 90 Min.") has no vocabulary picks beside it. The bug was prospective — it would have bitten the next Konfi author who used both.Also confirmed while checking: the migration already rewrites the legacy
ext:ekw:konfi:<slug>:customkey to the bare scalar shape, so that value is carried across correctly rather than landing on an illegal key. Simulated over the corpus: 0 ext keys outside the grammar afterwards.Not in this commit
amb-nostr-converter'sreconstructExt(nostrToAmb.ts:511-519) has the identical first-tag-wins defect and drops the scalar with no warning — verified by probe. AMB JSON export is still lossy until it lands. Same fix, same reference implementation. Owned by NIP-BOSS onfix/amb-ext-grammar.