Repository navigation
Commit 9b37edb
feat(spec)!: a type:'url' action's target declares its closed scheme set — http(s)://, mailto: and in-app paths — at both url-action doors (#23076)
Fixes #23010
Clause-②: no (narrowing: the published accept set of a `type: 'url'`
target narrows; the lane charter reads a narrowing as `no`, where the
card's own text says `yes`, and `yes` would not be wrong either)
The spec half of ruling A+ on objectstack-ai/objectui#12191
(maintainer's word, carried verbatim in the card: 「22861 objectui#12191
同意。」). The runner half landed as objectstack-ai/objectui#12230
(`334f0cff98`).
## What changed
On both url-action doors, a `type: 'url'` action's `target` now has a
closed scheme set, the same set the console's action runner opens at the
click:
- an `http://` or `https://` URL (the scheme compared
case-insensitively);
- a `mailto:` address;
- an in-app path that starts with `/` or `./`;
- a template that opens with `${`. The runner judges it after
interpolation, at the click; the door leaves it alone.
Every other target is refused at `target`, with a message that names the
reason and the admitted set: `javascript:`, `data:`, `tel:` and every
other scheme; `http:` or `https:` without the `//`; a protocol-relative
`//host`; `/` followed by a backslash; a tab, line feed or carriage
return anywhere; and a bare relative target such as `thanks`,
`../thanks`, `?tab=2` or `#top`. `target` on `script` / `flow` / `modal`
/ `api` / `form` is untouched, and so are interpolation and `openIn`.
- `packages/spec/src/ui/navigation-target.ts` (new): the one grammar,
`navigationTargetRefusal(raw, subject)`. It is reached by relative
import only, like `action-target-aliases.ts`, because `action.zod.ts`
rides the `ui` barrel and an export there would grow the published API.
#23004 (the empty-state link, `Blocked-by` this card) can import it from
`ui/view.zod.ts` with no copy. The admitted schemes are one table; the
refusal text is built from it, so admitting a scheme is one row.
- `packages/spec/src/ui/action.zod.ts`: one `superRefine` per door
(`ActionSchema`, and `InlineActionSchema` after the legacy `type:
'navigation'` / `to` fold), scoped to `type: 'url'`. An empty target is
left to the target-required rule, so it reports once. The `target`
describe and TSDoc state the set.
- D3 entry `ui-action-url-target-scheme-set-closed` and
`.changeset/23010-url-action-target-scheme-set.md` (`minor`, `Clause-②:
no (narrowing)`, ADR-0087 `registered`, FROM → TO table).
- Fixture triage: one fixture changed. `src/ui/action.test.ts`'s
all-types loop gave its url arm the bare `test_handler`. That was never
a navigation target; the fixture now writes `/test_handler`, and the
other arms keep their names.
- `content/docs/references/**`: regenerated for the describe (4 files,
`check:generated` named only `check:docs`).
- **File surface, beyond the claim's literal list (declared):**
`navigation-target.ts` is a new sibling module because the dispatch
delegated the predicate's placement and asked for it non-public, and
`action.zod.ts` is barrel-exported whole (`ui/index.ts`: `export * from
'./action.zod'`). The D3 entry file is what
`check:adr-0087-registration` requires: a changeset that carries a FROM
→ TO prescription cannot claim `no-migration-prescription`.
## The lane decision, measured against the runner
The door admits, for a literal target, exactly what objectui's
`classifyNavigationTarget` admits
(`packages/core/src/actions/navigationTarget.ts`, read at objectui
`origin/main` `7578323`): tab / LF / CR anywhere refused; `^https?://`
(case-insensitive) admitted; `^mailto:` (case-insensitive) admitted;
`./` admitted; `/` admitted unless its second character is `/` or a
backslash; everything else refused. **No difference was found between
the classifier and the lane paragraph.** The test file's section 3
replays the runner's own pinned cases from
`navigationTarget.mailto-12191.test.ts` at that sha and answers each the
same way at the door.
## Fork clause: no producer writes a refused target
The instrument looked at every literal within 8 lines of a `type` /
`actionType` of `'url'` or `'navigation'`, in TS, JS, JSON, MD and MDX,
plus YAML `type: url`. Positive control: the showcase's `OpenDocsAction`
(`https://objectstack.ai`) is found.
- objectstack at `df7a137923`: 9 url-action target sites outside tests,
all admitted. The hits it refuses are not url-action targets. They are
`target: '_blank'` link attributes on app navigation items
(`content/docs/ui/apps.mdx` ×3) and a neighbouring `type: 'form'` target
(`content/docs/ui/forms.mdx:415`).
- objectui at the pin `9646e2ed2bf3` and at `origin/main` `7578323`: 8
sites each, all admitted. The refused hits are `_blank` nav attributes
(`apps/console/src/preview-samples.ts`,
`content/docs/guide/designing-app-navigation.md`,
`packages/types/src/app.ts`).
- A grep for a non-admitted scheme literal in any `target` / `to` /
`actionUrl` / `navigate` / `newTabUrl` value finds tests only: objectui
`ActionRunner.test.ts`, `ActionRunner.onSuccessNavigation.test.ts` and
`element-button-mailto-12191.test.tsx` (main only). These tests exercise
the runner's refusal.
## Every schema path where a url-action target is authored
Reach was measured through `ObjectStackDefinitionSchema.safeParse`, the
parse `os validate` runs, and `defineStack`, with `javascript:alert(1)`
as the target. Where the props bag is judged by `@objectstack/lint`'s
`validateComponentProps`, that gate was measured too.
| path | status | measured |
| --- | --- | --- |
| `ActionSchema.target`, `type: 'url'` (registered actions; also reached
by `record:alert`, page toolbars and `action:bar`, which name declared
actions) | **changed** | refused at `actions.0.target` by `defineStack`
and the stack parse |
| `InlineActionSchema.target` / `to`, `type: 'url'` or `'navigation'`
(`element:button`'s `action`) | **changed** | `ElementButtonPropsSchema`
refuses at `action.target`. A page's `properties` bag is outside the
stack parse, so `os validate` reports it through the props gate as a
`component-props-invalid` **warning**. That gate is advisory by its own
header, and its posture is not this card's. |
| `action:button` / `action:icon` rows, `actionType: 'url'` + `target`
(`component.zod.ts`) | out of scope: outside this card's two named doors
and outside the claim's file surface | accepted, 0 findings |
| `action:group` / `action:menu` members, `type: 'url'` + `target`
(`component.zod.ts`) | out of scope (same reason) | accepted, 0 findings
|
| `DashboardHeaderActionSchema.actionUrl` with `actionType: 'url'` or
omitted, which objectui's `DashboardRenderer` defaults to `'url'`
(`dashboard.zod.ts`) | out of scope (same reason) | accepted |
| `ActionSchema.onSuccess.navigate` (`api` / `script` actions; the
runner classifies it too) | out of scope: a different key, with a
`${result.*}` scope; no ruling covers it | accepted |
| `ActionSchema.newTabUrl` | out of scope: the runner does not classify
it (only `ActionDef` declares it) | not measured |
| App navigation `{ type: 'url', url }`, `FormView.submitBehavior.url` |
not url actions. The first is app-shell nav; the second has its own
stricter relative-only rule. | n/a |
The four out-of-scope rows are one family: the doors would reuse
`navigationTargetRefusal` with no second grammar. They are reported to
the seat, not filed here.
## Tests and gates
All spec and gate readings are at `64b6470c9`, the branch head after
merging `origin/main` `c5b43b606`.
- `pnpm --filter @objectstack/spec build`: exit 0. `check:generated`
first named one stale artifact, `check:docs`. `gen:docs` rewrote the 4
reference pages. At `64b6470c9`, `check:generated` exits 0.
- `pnpm --filter @objectstack/spec typecheck`: exit 0.
- `@objectstack/spec` suite (`vitest run --project local`): 655 files,
19790 passed, 1 todo, exit 0. The new
`action-url-target-scheme-set.test.ts` holds 148 tests.
- **Ablation** (one-time proof, not kept).
`scripts/ablation-replace.mjs` deleted the registered door's
`.superRefine(refuseUnopenableUrlTarget(…))`: anchor 1 → 0, blob
`2ef003fa9e4f` → `e0687bd797b0`. The new file then read **48 failed /
100 passed**: the 19 registered-door refusals, the 19 admitted-set
checks and the 10 runner-parity refusals. The inline-door and acceptance
cases stayed green, as expected. After the restore the blob equals HEAD
(`2ef003fa9e4f`) and `git diff HEAD` is empty.
- Consumers ran at `f22d8b80f`, the pre-merge head. The merge's added
lines carry no url-action producer; the only hit is one changeset prose
line.
- `@objectstack/lint`, full suite: 137 files, 6569 passed.
- `@objectstack/platform-objects`, full suite: 70 files, 1096 passed.
- `@objectstack/example-showcase`, full suite: 33 files, 409 passed.
- `@objectstack/runtime`, **narrowed**: 7 files, 142 passed.
- `@objectstack/cli --project unit`, **narrowed**: 4 files, 76 passed.
Two of the files the narrowing named,
`generate-string-family-width.pin.test.ts` and
`verify-author-time-stage.test.ts`, are in the `integration` tier. That
tier is declared to CI.
- The narrowing, declared. The population is every test file that writes
a `type` / `actionType` of `'url'` or `'navigation'`, or that imports
`ActionSchema`, `InlineActionSchema` or `defineAction`: 7 of 360 runtime
test files and 8 of 479 cli test files. Every other test reads only
in-tree producers, and those are all measured admitted (above), so their
verdict cannot move. Blind spot: a test that builds a url action with a
computed `type`. CI runs both full suites.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (no paths) derived 111 commands at
`64b6470c9`. **All 111 exit 0.** `--ran` reconciles 111 run, 0
NOT-MEASURED, 0 UNRUN. The first pass, at `f22d8b80f`, had 3 exit-3
prerequisite refusals, all green at `64b6470c9`:
- the teardown self-test's pinned control commit was missing from the
shallow clone (fetched, then green);
- `check:skill-examples` needed `@objectstack/client-react` built;
- `check:dual-build-cjs-loads` needed 8 unbuilt packages.
- `check-adr-0087-registration`: `registered
ui-action-url-target-scheme-set-closed (new here)`. `check:nul-bytes`
and the raw control-byte self-scan of the touched files are both clean.
- Not run locally, declared to CI: the full `@objectstack/cli` and
`@objectstack/runtime` suites, and the cli `integration` tier.
## Acceptance notes
- `content/docs/protocol/objectui/actions.mdx` § URL Actions teaches two
admitted targets (`/_console/dashboards/…`,
`https://www.google.com/maps/…`) and does not state the set. The
reference page now carries it through the describe. A one-sentence
statement there is a docs nicety outside the claim's surface.
- The props gate's warning posture means an `element:button` with a
refused inline target still publishes with a warning, until that gate's
own error upgrade.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01GZSPSQ5ArPjSJdxxuqwGoZ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 038d4c9 commit 9b37edb
10 files changed
Lines changed: 513 additions & 12 deletions
File tree
- .changeset
- content/docs/references
- data
- kernel
- ui
- packages/spec/src
- migrations/entries/semantic
- ui
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
456 | 456 | | |
457 | 457 | | |
458 | 458 | | |
459 | | - | |
| 459 | + | |
460 | 460 | | |
461 | 461 | | |
462 | 462 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
320 | 320 | | |
321 | 321 | | |
322 | 322 | | |
323 | | - | |
| 323 | + | |
324 | 324 | | |
325 | 325 | | |
326 | 326 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
36 | 36 | | |
37 | 37 | | |
38 | 38 | | |
39 | | - | |
| 39 | + | |
40 | 40 | | |
41 | 41 | | |
42 | 42 | | |
| |||
308 | 308 | | |
309 | 309 | | |
310 | 310 | | |
311 | | - | |
| 311 | + | |
312 | 312 | | |
313 | 313 | | |
314 | 314 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
214 | 214 | | |
215 | 215 | | |
216 | 216 | | |
217 | | - | |
| 217 | + | |
218 | 218 | | |
219 | 219 | | |
220 | 220 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
0 commit comments