@swimmesberger/elarion-contributions 0.2.3-preview.72.1 → 0.2.3-preview.74.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,9 +6,14 @@ typed extension-point tokens, declarative module manifests, and capability-gated
6
6
  It extends the backend's review-isolation rule — *a module only touches its own code* — to the frontend:
7
7
  adding a sidebar item or a cross-module action edits one file in the owning module, never the shell.
8
8
 
9
- - **Framework-free core** (`@swimmesberger/elarion-contributions`) — no dependencies, no React.
9
+ - **Framework-free core** (`@swimmesberger/elarion-contributions`) — no dependencies, no React. `when`
10
+ clauses are typed **strictly** against your vocabulary (a typo'd permission is a compile error, and
11
+ vocabulary axes are optional — a no-auth app binds `{ module }` only and any stray axis use fails to
12
+ compile), `ItemOf`/`ContextOf` extract a point's declared types, and `createStaticCapabilities` is
13
+ the `CapabilityReader` for apps without a session snapshot yet.
10
14
  - **React bindings** (`@swimmesberger/elarion-contributions/react`) — `ContributionProvider`,
11
- `useContributions`, `<ExtensionSlot>`. React is an optional peer dependency; other view frameworks
15
+ `useContributions`, `<ExtensionSlot>` (its `context` prop is type-checked against the point's
16
+ `TContext` and handed to the render prop). React is an optional peer dependency; other view frameworks
12
17
  can port the bindings in a page of code.
13
18
  - **Angular bindings** (`@swimmesberger/elarion-contributions/angular`) — `provideContributions`,
14
19
  `injectContributions` (returns a `Signal`). Idiomatic for Angular 20–22: signal-first, standalone,
@@ -25,6 +30,14 @@ adding a sidebar item or a cross-module action edits one file in the owning modu
25
30
  npm install @swimmesberger/elarion-contributions
26
31
  ```
27
32
 
33
+ > [!IMPORTANT]
34
+ > **Vite users:** the `/react` bindings call hooks, so they must resolve to your app's **single** React
35
+ > instance. If the first `useContributions` throws `Invalid hook call … Cannot read properties of null
36
+ > (reading 'useContext')`, Vite's dependency optimizer pre-bundled the package against a second React
37
+ > copy. Add `resolve: { dedupe: ["react", "react-dom"] }` plus an `optimizeDeps.include` entry for the
38
+ > package (and its `/react` sub-export) to `vite.config.ts`, then clear `node_modules/.vite`. Vite does
39
+ > not consult peer-dependency metadata when pre-bundling, so there is no package-side fix.
40
+
28
41
  ## What you import vs. what you own
29
42
 
30
43
  This package ships the *machinery* with fixed semantics; your application owns the *points* and the shell
@@ -32,10 +45,11 @@ This package ships the *machinery* with fixed semantics; your application owns t
32
45
 
33
46
  | You import (fixed semantics) | You own (copy from the sample) |
34
47
  | --- | --- |
35
- | `defineExtensionPoint`, `defineModule`, `contribute` | Your extension points (`sidebarItems`, …) and their payload types |
36
- | `evaluateWhen` + the `when` AND semantics | The kit instantiation binding your generated vocabulary |
37
- | `createContributionRegistry` (filter + deterministic order) | The app shell that renders each slot |
38
- | `ContributionProvider` / `useContributions` / `<ExtensionSlot>` (React) | Route composition and module discovery (e.g. `import.meta.glob`) |
48
+ | `defineExtensionPoint`, `defineModule`, `contribute`, `ItemOf`/`ContextOf` | Your extension points (`sidebarItems`, …) and their payload types |
49
+ | `evaluateWhen` + the strict `when` AND semantics | The kit instantiation binding your (generated or hand-authored) vocabulary |
50
+ | `createContributionRegistry` (filter + deterministic order + id validation) | The app shell that renders each slot |
51
+ | `createStaticCapabilities` (the no-snapshot `CapabilityReader`) | The real snapshot wiring once `elarion.session` exists |
52
+ | `ContributionProvider` / `useContributions` / `<ExtensionSlot context=…>` (React) | Route composition and module discovery (e.g. `import.meta.glob`) |
39
53
  | `provideContributions` / `injectContributions` (Angular) | Slot rendering — an `@for` block, or a small `*extensionSlot` directive you own (see below) |
40
54
  | `redirectUnless` route guard (`/tanstack-router`) | Everything else routing — the router's own API |
41
55
 
@@ -59,6 +73,12 @@ export type AppManifest = ModuleManifest<AppVocabulary>
59
73
  export const { defineModule, defineExtensionPoint, contribute } = createContributionKit<AppVocabulary>()
60
74
  ```
61
75
 
76
+ Declare only the axes your application has — they are all optional, and `when` clauses are checked
77
+ **strictly**: a typo'd name or a clause on an undeclared axis is a compile error, not a silently hidden
78
+ item. No auth and no generated `session-client.ts` yet? Bind `{ module: ModuleName }` with a hand-authored
79
+ union and use `createStaticCapabilities()` as the reader (modules/permissions/roles default `"all"`, flags
80
+ default none); swap in the generated `SessionCapabilities` later — same structural interface.
81
+
62
82
  Declare a point where the *owner* of the slot lives (the shell for a sidebar, a module for its own
63
83
  extension surface), and export the token from the owner's public entry. The payload shape is yours —
64
84
  whatever your shell needs to render:
@@ -99,6 +119,16 @@ const registry = createContributionRegistry(appModules.map((m) => m.manifest), c
99
119
  const items = useContributions(sidebarItems)
100
120
  ```
101
121
 
122
+ When a point declares a slot context (`defineExtensionPoint<TItem, TContext>`), the slot site supplies it
123
+ through `<ExtensionSlot context=…>` — type-checked against `TContext` and handed to the render prop — and
124
+ payloads pin their signatures to the declaration with `ContextOf`:
125
+
126
+ ```tsx
127
+ // the point: export const stackDetailTabs = defineExtensionPoint<StackTab, { stack: Stack }>("stacks.detailTabs")
128
+ // the payload type: component: (context: ContextOf<typeof stackDetailTabs>) => ReactNode
129
+ <ExtensionSlot point={stackDetailTabs} context={{ stack }} render={(tab, ctx) => tab.component(ctx)} />
130
+ ```
131
+
102
132
  ## Angular
103
133
 
104
134
  Same kernel, idiomatic Angular surface. `provideContributions` is an environment provider (shaped like
@@ -178,9 +208,14 @@ export class ExtensionSlotDirective<TItem> {
178
208
  - **`when` is AND** across its fields (`module`, `permission`, `flag`, `role`), mirroring
179
209
  `[RequirePermission]`/`[RequireRole]`/`[FeatureGate]`; a manifest-level `when` is ANDed into every
180
210
  contribution. Absent capabilities fail closed.
181
- - **Resolution is pure and deterministic**: contributions sort by `order` (default 0), then `id`, then
182
- contributing module name, using code-unit comparison the same input renders the same UI on server
183
- and client. Refreshing the snapshot means building a new registry.
211
+ - **`when` is strictly typed** against the kit's vocabulary: out-of-vocabulary names do not compile
212
+ (fail-closed evaluation would otherwise turn a typo into invisibly hidden UI). Only a manifest's
213
+ `name` accepts free strings it is an identity, not a lookup.
214
+ - **Resolution is pure and deterministic**: contributions sort by `order` (default 0), then `id`, using
215
+ code-unit comparison — the same input renders the same UI on server and client. Two co-visible
216
+ contributions to one point sharing an id throw at resolution (ids double as render keys); prefix ids
217
+ with the module name (`"invoicing.create-invoice"`). Refreshing the snapshot means building a new
218
+ registry.
184
219
  - **UX projection, never security.** A hidden contribution is not a secured operation — the backend
185
220
  handler's own gates are the enforcement on every call.
186
221
 
package/dist/index.d.ts CHANGED
@@ -1,24 +1,42 @@
1
1
  /**
2
2
  * The capability vocabulary a contribution kit is typed against — supplied by the application from the
3
- * generated literal unions in `session-client.ts` (ModuleName/PermissionName/FlagName/RoleName).
3
+ * generated literal unions in `session-client.ts` (ModuleName/PermissionName/FlagName/RoleName). Declare
4
+ * only the axes the application actually has: an omitted (or `never`) axis makes every `when` use of it
5
+ * a compile error, so a no-auth app binding `{ module: ModuleName }` gets a real error on a stray
6
+ * permission/flag/role clause instead of a silently hidden item.
4
7
  */
5
8
  export interface Vocabulary {
6
- module: string;
7
- permission: string;
8
- flag: string;
9
- role: string;
9
+ module?: string;
10
+ permission?: string;
11
+ flag?: string;
12
+ role?: string;
10
13
  }
11
- /** Keeps literal-union autocomplete while accepting out-of-vocabulary names (the generated accessors' shape). */
14
+ /**
15
+ * Keeps literal-union autocomplete while accepting out-of-vocabulary names. Used only where a name is an
16
+ * *identity* (a UI-only module's `name`), never where it is a capability lookup — a lookup outside the
17
+ * vocabulary is exactly the drift `when` typing exists to catch.
18
+ */
12
19
  type Hint<T extends string> = T | (string & {});
20
+ /**
21
+ * One `when`-clause axis: exactly the vocabulary's names. `Extract` keeps the unbound default permissive
22
+ * (`Vocabulary`'s optional `string` axes stay plain `string`) while an omitted or `never` axis reduces to
23
+ * `never` — unusable, by design.
24
+ */
25
+ type Axis<V extends Vocabulary, K extends keyof Vocabulary> = Extract<V[K], string>;
13
26
  /**
14
27
  * The declarative visibility condition of a contribution — the frontend mirror of
15
28
  * `[RequirePermission]`/`[RequireRole]`/`[FeatureGate]`: every present field must hold (AND).
29
+ *
30
+ * Axes are checked strictly against the vocabulary: a typo'd or out-of-vocabulary name is a compile
31
+ * error, not a silently hidden item (the evaluator fails closed, so a wrong name would never surface at
32
+ * runtime either). Deliberately stricter than the generated snapshot accessors, which accept
33
+ * `Name | (string & {})`.
16
34
  */
17
35
  export interface WhenClause<V extends Vocabulary = Vocabulary> {
18
- readonly module?: Hint<V["module"]>;
19
- readonly permission?: Hint<V["permission"]>;
20
- readonly flag?: Hint<V["flag"]>;
21
- readonly role?: Hint<V["role"]>;
36
+ readonly module?: Axis<V, "module">;
37
+ readonly permission?: Axis<V, "permission">;
38
+ readonly flag?: Axis<V, "flag">;
39
+ readonly role?: Axis<V, "role">;
22
40
  }
23
41
  /**
24
42
  * The snapshot surface resolution reads — structurally satisfied by the `SessionCapabilities` class the
@@ -31,6 +49,34 @@ export interface CapabilityReader {
31
49
  isFlagEnabled(name: string): boolean;
32
50
  }
33
51
  export declare function evaluateWhen(when: WhenClause | undefined, capabilities: CapabilityReader): boolean;
52
+ /** One axis of {@link createStaticCapabilities}: everything, an allow-list, or an explicit on/off map. */
53
+ export type StaticCapabilitySet = "all" | ReadonlyArray<string> | Readonly<Record<string, boolean>>;
54
+ /**
55
+ * A fixed {@link CapabilityReader} for applications without a session snapshot — self-hosted/no-auth
56
+ * deployments, tests, stories. Modules, permissions, and roles default to `"all"` (a no-auth app shows
57
+ * everything it ships); flags default to none, because a flag-gated contribution should stay hidden until
58
+ * the flag system that owns it exists (`when` stays fail-closed).
59
+ *
60
+ * This is a static snapshot by design, resolved once at composition. When the backend ships the
61
+ * `elarion.session` operation, swap it for the generated `SessionCapabilities` — same structural
62
+ * interface, nothing else changes.
63
+ *
64
+ * @example
65
+ * ```ts
66
+ * // Everything on (e.g. behind an authenticating reverse proxy):
67
+ * const capabilities = createStaticCapabilities()
68
+ * // Env-driven module toggles; permissions/roles open, flags off:
69
+ * const capabilities = createStaticCapabilities({
70
+ * modules: { core: true, "ai-agent": import.meta.env.VITE_MODULE_AI_AGENT_ENABLED !== "false" },
71
+ * })
72
+ * ```
73
+ */
74
+ export declare function createStaticCapabilities(init?: {
75
+ readonly modules?: StaticCapabilitySet;
76
+ readonly permissions?: StaticCapabilitySet;
77
+ readonly roles?: StaticCapabilitySet;
78
+ readonly flags?: StaticCapabilitySet;
79
+ }): CapabilityReader;
34
80
  declare const CONTRIBUTION: unique symbol;
35
81
  declare const SLOT_CONTEXT: unique symbol;
36
82
  /**
@@ -49,11 +95,24 @@ export interface ExtensionPoint<TItem, TContext = void> {
49
95
  readonly [SLOT_CONTEXT]?: TContext;
50
96
  }
51
97
  export declare function defineExtensionPoint<TItem, TContext = void>(id: string): ExtensionPoint<TItem, TContext>;
98
+ /** The payload type an extension point accepts — `ItemOf<typeof sidebarItems>`. */
99
+ export type ItemOf<P extends ExtensionPoint<unknown, unknown>> = P extends ExtensionPoint<infer TItem, infer _TContext> ? TItem : never;
100
+ /**
101
+ * The slot context an extension point declares — `ContextOf<typeof stackDetailTabs>`. Reference it in the
102
+ * point's payload signatures (`component: (context: ContextOf<typeof stackDetailTabs>) => ReactNode`) and
103
+ * receive it from the slot (`<ExtensionSlot context={…}>` in the React bindings), so the payload, the
104
+ * point, and the slot site can never drift apart.
105
+ */
106
+ export type ContextOf<P extends ExtensionPoint<unknown, unknown>> = P extends ExtensionPoint<infer _TItem, infer TContext> ? TContext : never;
52
107
  /** A single contribution: the point's payload plus identity, ordering, and visibility. */
53
108
  export type Contribution<TItem, V extends Vocabulary = Vocabulary> = TItem & {
54
- /** Unique within the point across all modules; part of the deterministic sort key. */
109
+ /**
110
+ * Unique within the point among co-visible contributions (enforced when the registry resolves — ids
111
+ * double as render keys). Prefix with the contributing module's name, e.g. `"invoicing.create-invoice"`,
112
+ * to stay collision-free across modules.
113
+ */
55
114
  readonly id: string;
56
- /** Ascending sort rank (default 0); ties break by id, then by contributing module name. */
115
+ /** Ascending sort rank (default 0); ties break by id. */
57
116
  readonly order?: number;
58
117
  /** Visibility condition, ANDed with the contributing module's manifest-level `when`. */
59
118
  readonly when?: WhenClause<V>;
@@ -72,7 +131,7 @@ export declare function contribute<TItem, TContext, V extends Vocabulary = Vocab
72
131
  */
73
132
  export interface ModuleManifest<V extends Vocabulary = Vocabulary> {
74
133
  /** The module's name — its backend [AppModule] name when it mirrors one, any unique name when UI-only. */
75
- readonly name: Hint<V["module"]>;
134
+ readonly name: Hint<Axis<V, "module">>;
76
135
  /**
77
136
  * Module-level visibility, ANDed into every contribution. A backend-paired module gates itself here once
78
137
  * — `when: { module: Modules.X }` — so disabling the backend module removes the whole frontend module;
@@ -91,8 +150,12 @@ export interface ContributionRegistry {
91
150
  * (manifests, capabilities) input yields the same registry, so a server render and the client hydration
92
151
  * produce identical trees. Refreshing the snapshot (login, context change) means building a new registry,
93
152
  * the same contract as the generated session provider.
153
+ *
154
+ * Resolution validates that no two co-visible contributions to one point share an id (ids double as
155
+ * render keys and as the deterministic tiebreak) and throws on a collision — manifests are static data,
156
+ * so a collision is a data bug that fails fast here rather than corrupting keyed rendering downstream.
94
157
  */
95
- export declare function createContributionRegistry(manifests: ReadonlyArray<ModuleManifest>, capabilities: CapabilityReader): ContributionRegistry;
158
+ export declare function createContributionRegistry<V extends Vocabulary = Vocabulary>(manifests: ReadonlyArray<ModuleManifest<V>>, capabilities: CapabilityReader): ContributionRegistry;
96
159
  /** The vocabulary-bound facade of the kernel — created once by the application, imported by every module. */
97
160
  export interface ContributionKit<V extends Vocabulary> {
98
161
  defineModule(manifest: ModuleManifest<V>): ModuleManifest<V>;
package/dist/index.js CHANGED
@@ -22,6 +22,47 @@ export function evaluateWhen(when, capabilities) {
22
22
  return false;
23
23
  return true;
24
24
  }
25
+ /**
26
+ * A fixed {@link CapabilityReader} for applications without a session snapshot — self-hosted/no-auth
27
+ * deployments, tests, stories. Modules, permissions, and roles default to `"all"` (a no-auth app shows
28
+ * everything it ships); flags default to none, because a flag-gated contribution should stay hidden until
29
+ * the flag system that owns it exists (`when` stays fail-closed).
30
+ *
31
+ * This is a static snapshot by design, resolved once at composition. When the backend ships the
32
+ * `elarion.session` operation, swap it for the generated `SessionCapabilities` — same structural
33
+ * interface, nothing else changes.
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * // Everything on (e.g. behind an authenticating reverse proxy):
38
+ * const capabilities = createStaticCapabilities()
39
+ * // Env-driven module toggles; permissions/roles open, flags off:
40
+ * const capabilities = createStaticCapabilities({
41
+ * modules: { core: true, "ai-agent": import.meta.env.VITE_MODULE_AI_AGENT_ENABLED !== "false" },
42
+ * })
43
+ * ```
44
+ */
45
+ export function createStaticCapabilities(init = {}) {
46
+ return {
47
+ isModuleEnabled: toPredicate(init.modules, true),
48
+ hasPermission: toPredicate(init.permissions, true),
49
+ hasRole: toPredicate(init.roles, true),
50
+ isFlagEnabled: toPredicate(init.flags, false),
51
+ };
52
+ }
53
+ function toPredicate(set, enabledByDefault) {
54
+ if (set === undefined)
55
+ return () => enabledByDefault;
56
+ if (set === "all")
57
+ return () => true;
58
+ if (Array.isArray(set)) {
59
+ const names = new Set(set);
60
+ return (name) => names.has(name);
61
+ }
62
+ // Array.isArray does not remove ReadonlyArray from the negated union (TS#17002), hence the assertion.
63
+ const map = set;
64
+ return (name) => map[name] === true;
65
+ }
25
66
  export function defineExtensionPoint(id) {
26
67
  return { id };
27
68
  }
@@ -46,6 +87,10 @@ const EMPTY = [];
46
87
  * (manifests, capabilities) input yields the same registry, so a server render and the client hydration
47
88
  * produce identical trees. Refreshing the snapshot (login, context change) means building a new registry,
48
89
  * the same contract as the generated session provider.
90
+ *
91
+ * Resolution validates that no two co-visible contributions to one point share an id (ids double as
92
+ * render keys and as the deterministic tiebreak) and throws on a collision — manifests are static data,
93
+ * so a collision is a data bug that fails fast here rather than corrupting keyed rendering downstream.
49
94
  */
50
95
  export function createContributionRegistry(manifests, capabilities) {
51
96
  const byPoint = new Map();
@@ -68,9 +113,17 @@ export function createContributionRegistry(manifests, capabilities) {
68
113
  }
69
114
  const resolved = new Map();
70
115
  for (const [pointId, entries] of byPoint) {
71
- entries.sort((a, b) => (a.item.order ?? 0) - (b.item.order ?? 0) ||
72
- compareStrings(a.item.id, b.item.id) ||
73
- compareStrings(a.module, b.module));
116
+ const contributors = new Map();
117
+ for (const entry of entries) {
118
+ const previous = contributors.get(entry.item.id);
119
+ if (previous !== undefined) {
120
+ throw new Error(`Duplicate contribution id "${entry.item.id}" on extension point "${pointId}" ` +
121
+ `(contributed by module "${previous}" and module "${entry.module}"). Contribution ids must ` +
122
+ `be unique within a point — prefix with the module name, e.g. "${entry.module}.${entry.item.id}".`);
123
+ }
124
+ contributors.set(entry.item.id, entry.module);
125
+ }
126
+ entries.sort((a, b) => (a.item.order ?? 0) - (b.item.order ?? 0) || compareStrings(a.item.id, b.item.id));
74
127
  resolved.set(pointId, entries.map((entry) => entry.item));
75
128
  }
76
129
  return {
package/dist/react.d.ts CHANGED
@@ -6,8 +6,26 @@ export declare function ContributionProvider({ registry, children, }: {
6
6
  }): import("react").JSX.Element;
7
7
  /** The resolved contributions for a point — already filtered by `when` and deterministically ordered. */
8
8
  export declare function useContributions<TItem, TContext>(point: ExtensionPoint<TItem, TContext>): ReadonlyArray<Contribution<TItem>>;
9
- /** Renders a point's contributions through a render prop — sugar over {@link useContributions} for inline slots. */
10
- export declare function ExtensionSlot<TItem, TContext>({ point, render, }: {
9
+ /**
10
+ * Renders a point's contributions through a render prop — sugar over {@link useContributions} for inline
11
+ * slots. When the point declares a slot context (`TContext`), pass it as `context` and the render prop
12
+ * receives it, typed, as its second argument — so what the slot site supplies can never drift from what
13
+ * the point declares:
14
+ *
15
+ * ```tsx
16
+ * <ExtensionSlot point={stackDetailTabs} context={{ stack }} render={(tab, ctx) => tab.component(ctx)} />
17
+ * ```
18
+ *
19
+ * Without `context`, the render prop takes only the item — for slots that render inert parts (buttons,
20
+ * menu entries) and hand the payload its context later, at invocation time.
21
+ */
22
+ export declare function ExtensionSlot<TItem, TContext>(props: {
23
+ point: ExtensionPoint<TItem, TContext>;
24
+ /** The slot context the point declares — handed to `render` as the second argument. */
25
+ context: TContext;
26
+ render: (item: Contribution<TItem>, context: TContext) => ReactNode;
27
+ }): ReactNode;
28
+ export declare function ExtensionSlot<TItem, TContext>(props: {
11
29
  point: ExtensionPoint<TItem, TContext>;
12
30
  render: (item: Contribution<TItem>) => ReactNode;
13
- }): import("react").JSX.Element;
31
+ }): ReactNode;
package/dist/react.js CHANGED
@@ -15,8 +15,10 @@ export function useContributions(point) {
15
15
  throw new Error("useContributions requires a <ContributionProvider> above it.");
16
16
  return registry.get(point);
17
17
  }
18
- /** Renders a point's contributions through a render prop sugar over {@link useContributions} for inline slots. */
19
- export function ExtensionSlot({ point, render, }) {
20
- const items = useContributions(point);
21
- return (_jsx(_Fragment, { children: items.map((item) => (_jsx(Fragment, { children: render(item) }, item.id))) }));
18
+ // Overloads rather than a props union: JSX contextually types the render prop per overload, where a
19
+ // union would leave the render parameters implicitly `any`. The context form must come first — JSX
20
+ // falls through cleanly on its *missing* `context` prop, but would not fall through past an *excess* one.
21
+ export function ExtensionSlot(props) {
22
+ const items = useContributions(props.point);
23
+ return (_jsx(_Fragment, { children: items.map((item) => (_jsx(Fragment, { children: props.render(item, props.context) }, item.id))) }));
22
24
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swimmesberger/elarion-contributions",
3
- "version": "0.2.3-preview.72.1",
3
+ "version": "0.2.3-preview.74.1",
4
4
  "description": "The Elarion frontend contribution model: typed extension points, declarative module manifests, and capability-gated resolution — framework-free core with React bindings under /react and Angular bindings under /angular.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -28,8 +28,8 @@
28
28
  ],
29
29
  "scripts": {
30
30
  "build": "tsc -p tsconfig.json",
31
- "typecheck": "tsc --noEmit -p tsconfig.json",
32
- "test": "vitest run",
31
+ "typecheck": "tsc --noEmit -p tsconfig.test.json",
32
+ "test": "npm run typecheck && vitest run",
33
33
  "prepack": "npm run build",
34
34
  "prepare": "npm run build"
35
35
  },