@ikenga/contract 0.9.1 → 0.11.0
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/dist/browser.d.ts +30 -0
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +23 -0
- package/dist/browser.js.map +1 -1
- package/dist/canvas/Canvas.d.ts.map +1 -1
- package/dist/canvas/Canvas.js +14 -2
- package/dist/canvas/Canvas.js.map +1 -1
- package/dist/canvas/types.d.ts +4 -0
- package/dist/canvas/types.d.ts.map +1 -1
- package/dist/canvas/use-pan-zoom.d.ts +2 -0
- package/dist/canvas/use-pan-zoom.d.ts.map +1 -1
- package/dist/canvas/use-pan-zoom.js +42 -3
- package/dist/canvas/use-pan-zoom.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +477 -97
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +73 -1
- package/dist/manifest.js.map +1 -1
- package/dist/pa-actions.d.ts +181 -0
- package/dist/pa-actions.d.ts.map +1 -0
- package/dist/pa-actions.js +92 -0
- package/dist/pa-actions.js.map +1 -0
- package/dist/registry.d.ts +582 -242
- package/dist/registry.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/browser.ts +28 -0
- package/src/canvas/Canvas.tsx +25 -1
- package/src/canvas/types.ts +4 -0
- package/src/canvas/use-pan-zoom.ts +43 -2
- package/src/index.ts +1 -0
- package/src/manifest.test.ts +95 -0
- package/src/manifest.ts +82 -1
- package/src/pa-actions.test.ts +92 -0
- package/src/pa-actions.ts +252 -0
package/dist/registry.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAeA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,eAAO,MAAM,uBAAuB,EAAG,CAAU,CAAC;AAIlD;;;;GAIG;AACH,eAAO,MAAM,YAAY;IACvB,4DAA4D;;IAE5D,0DAA0D;;;;;;;;EAE1D,CAAC;AACH,MAAM,MAAM,MAAM,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,CAAC;AAElD;;;;GAIG;AACH,eAAO,MAAM,sBAAsB;;;;;;;;;EAGjC,CAAC;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAEtE,eAAO,MAAM,gBAAgB;IAC3B,4BAA4B;;IAE5B,iDAAiD;;IAEjD,yEAAyE;;IAEzE,2DAA2D;;IAE3D,mFAAmF;;IAEnF,4DAA4D
|
|
1
|
+
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QAE5D,gFAAgF;;YA/BhF,4DAA4D;;YAE5D,0DAA0D;;;;;;;;;QA+B1D;;;;;;WAMG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAaH,CAAC;AACH,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAIxD,eAAO,MAAM,mBAAmB;IAC9B,qBAAqB;;IAErB,qBAAqB;;IAErB;;;;;OAKG;;IAEH,4EAA4E;;IAE5E,2EAA2E;;IAE3E;;;;OAIG;;IAEH;;;;;;;OAOG;;;;;;;;;;;;;;;;;;EAEH,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEhE,eAAO,MAAM,mBAAmB;;IAE9B,wDAAwD;;;QAnCxD,qBAAqB;;QAErB,qBAAqB;;QAErB;;;;;WAKG;;QAEH,4EAA4E;;QAE5E,2EAA2E;;QAE3E;;;;WAIG;;QAEH;;;;;;;WAOG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAUH,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAIhE;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,8EAA8E;AAC9E,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAErD"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ikenga/contract",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Shared contract for the Ikenga pkg system: manifest schema, RPC types, Engine interface, capability scopes.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -51,6 +51,10 @@
|
|
|
51
51
|
"./browser": {
|
|
52
52
|
"types": "./dist/browser.d.ts",
|
|
53
53
|
"import": "./dist/browser.js"
|
|
54
|
+
},
|
|
55
|
+
"./pa-actions": {
|
|
56
|
+
"types": "./dist/pa-actions.d.ts",
|
|
57
|
+
"import": "./dist/pa-actions.js"
|
|
54
58
|
}
|
|
55
59
|
},
|
|
56
60
|
"files": [
|
package/src/browser.ts
CHANGED
|
@@ -43,6 +43,21 @@ export const BrowserRefSchema = z
|
|
|
43
43
|
|
|
44
44
|
export type BrowserRef = z.infer<typeof BrowserRefSchema>;
|
|
45
45
|
|
|
46
|
+
// ---------- Engine ----------
|
|
47
|
+
//
|
|
48
|
+
// Which underlying browser engine backs a pane. `webkit` (default) is the
|
|
49
|
+
// in-shell child-webview (WebKitGTK on Linux / WKWebView / WebView2). `chrome`
|
|
50
|
+
// is "Managed mode": the shell launches the user's installed Google Chrome with
|
|
51
|
+
// a dedicated `--user-data-dir` + `--remote-debugging-port` and drives it over
|
|
52
|
+
// CDP. The engine is chosen at `open()`; subsequent actions address the pane by
|
|
53
|
+
// id and inherit its engine. See plans/chrome-pkg/01-plan.md §Architecture.
|
|
54
|
+
|
|
55
|
+
export const BROWSER_ENGINES = ['webkit', 'chrome'] as const;
|
|
56
|
+
|
|
57
|
+
export const BrowserEngineSchema = z.enum(BROWSER_ENGINES);
|
|
58
|
+
|
|
59
|
+
export type BrowserEngine = z.infer<typeof BrowserEngineSchema>;
|
|
60
|
+
|
|
46
61
|
// ---------- Snapshot ----------
|
|
47
62
|
//
|
|
48
63
|
// Mirrors the shape of Iyke's `DomResult` so models can move between the
|
|
@@ -119,8 +134,17 @@ export const BrowserOpenInputSchema = z
|
|
|
119
134
|
* Optional initial rect in main-window-client coordinates. Omitted when
|
|
120
135
|
* the FE owns layout (the kernel will refuse without a rect; the MCP
|
|
121
136
|
* layer is expected to supply a sane default if the caller didn't).
|
|
137
|
+
*
|
|
138
|
+
* Ignored for `engine: "chrome"` — Managed Chrome renders in its own OS
|
|
139
|
+
* window, not a shell pane.
|
|
122
140
|
*/
|
|
123
141
|
rect: RectSchema.optional(),
|
|
142
|
+
/**
|
|
143
|
+
* Which engine backs this pane. Defaults to `"webkit"` (the in-shell
|
|
144
|
+
* child-webview). `"chrome"` is Managed mode (installed Chrome over CDP).
|
|
145
|
+
* The pkg must declare the engine in `capabilities.webview.engines`.
|
|
146
|
+
*/
|
|
147
|
+
engine: BrowserEngineSchema.default('webkit'),
|
|
124
148
|
})
|
|
125
149
|
.refine((v) => !(v.partition && v.session), {
|
|
126
150
|
message: 'pass at most one of `partition` / `session`',
|
|
@@ -132,6 +156,8 @@ export const BrowserOpenResultSchema = z.object({
|
|
|
132
156
|
id: BrowserPaneIdSchema,
|
|
133
157
|
url: z.string(),
|
|
134
158
|
partition: z.string(),
|
|
159
|
+
/** The resolved engine backing this pane (`"webkit"` if absent, for back-compat). */
|
|
160
|
+
engine: BrowserEngineSchema.default('webkit'),
|
|
135
161
|
});
|
|
136
162
|
|
|
137
163
|
export type BrowserOpenResult = z.infer<typeof BrowserOpenResultSchema>;
|
|
@@ -147,6 +173,8 @@ export const BrowserListEntrySchema = z.object({
|
|
|
147
173
|
url: z.string(),
|
|
148
174
|
partition: z.string(),
|
|
149
175
|
focused: z.boolean(),
|
|
176
|
+
/** Which engine backs this pane (`"webkit"` if absent, for back-compat). */
|
|
177
|
+
engine: BrowserEngineSchema.default('webkit'),
|
|
150
178
|
});
|
|
151
179
|
|
|
152
180
|
export type BrowserListEntry = z.infer<typeof BrowserListEntrySchema>;
|
package/src/canvas/Canvas.tsx
CHANGED
|
@@ -51,6 +51,8 @@ function CanvasInner<T>(props: CanvasProps<T>, ref: React.Ref<CanvasHandle>) {
|
|
|
51
51
|
onEditModeChange,
|
|
52
52
|
onSelectionChange,
|
|
53
53
|
autoFitOnResize = true,
|
|
54
|
+
keyboardPan = true,
|
|
55
|
+
ariaLabel = 'Canvas',
|
|
54
56
|
className,
|
|
55
57
|
} = props;
|
|
56
58
|
|
|
@@ -58,6 +60,7 @@ function CanvasInner<T>(props: CanvasProps<T>, ref: React.Ref<CanvasHandle>) {
|
|
|
58
60
|
layout,
|
|
59
61
|
editMode,
|
|
60
62
|
autoFitOnResize,
|
|
63
|
+
keyboardPan,
|
|
61
64
|
onViewportChange,
|
|
62
65
|
onEditModeChange,
|
|
63
66
|
onSelectionChange,
|
|
@@ -83,6 +86,14 @@ function CanvasInner<T>(props: CanvasProps<T>, ref: React.Ref<CanvasHandle>) {
|
|
|
83
86
|
const id = itemEl.getAttribute('data-id') as ItemId | null;
|
|
84
87
|
if (!id) return;
|
|
85
88
|
onSelectionChange?.(id);
|
|
89
|
+
// Move DOM focus to the newly-selected item so keyboard users land on it
|
|
90
|
+
// and screen readers announce the selection (WCAG 2.4.3 Focus Order). The
|
|
91
|
+
// rAF waits for React to commit the tabIndex={0} / aria-selected update.
|
|
92
|
+
requestAnimationFrame(() =>
|
|
93
|
+
canvasRef.current
|
|
94
|
+
?.querySelector<HTMLElement>(`[data-id="${CSS.escape(id)}"]`)
|
|
95
|
+
?.focus()
|
|
96
|
+
);
|
|
86
97
|
beginDrag(id, e.clientX, e.clientY);
|
|
87
98
|
e.preventDefault();
|
|
88
99
|
return;
|
|
@@ -107,6 +118,9 @@ function CanvasInner<T>(props: CanvasProps<T>, ref: React.Ref<CanvasHandle>) {
|
|
|
107
118
|
ref={canvasRef}
|
|
108
119
|
className={className ? `ikenga-canvas ${className}` : 'ikenga-canvas'}
|
|
109
120
|
data-mode-edit={editMode ? 'true' : 'false'}
|
|
121
|
+
role="application"
|
|
122
|
+
aria-label={ariaLabel}
|
|
123
|
+
tabIndex={0}
|
|
110
124
|
onMouseDown={onMouseDown}
|
|
111
125
|
onDoubleClick={onDoubleClick}
|
|
112
126
|
>
|
|
@@ -144,7 +158,17 @@ function CanvasInner<T>(props: CanvasProps<T>, ref: React.Ref<CanvasHandle>) {
|
|
|
144
158
|
'data-id': id,
|
|
145
159
|
style: injected,
|
|
146
160
|
className,
|
|
147
|
-
|
|
161
|
+
// Selected item is programmatically focusable (tabIndex 0) and
|
|
162
|
+
// announces its selected state; unselected items stay out of the
|
|
163
|
+
// tab sequence (tabIndex -1) like a roving tablist (WCAG 2.4.3).
|
|
164
|
+
'aria-selected': isSelected,
|
|
165
|
+
tabIndex: isSelected ? 0 : -1,
|
|
166
|
+
} as Partial<typeof el.props> & {
|
|
167
|
+
key: string;
|
|
168
|
+
'data-id': string;
|
|
169
|
+
'aria-selected': boolean;
|
|
170
|
+
tabIndex: number;
|
|
171
|
+
});
|
|
148
172
|
})}
|
|
149
173
|
</div>
|
|
150
174
|
|
package/src/canvas/types.ts
CHANGED
|
@@ -38,6 +38,10 @@ export interface CanvasProps<T> {
|
|
|
38
38
|
onEditModeChange?: (editMode: boolean) => void;
|
|
39
39
|
onSelectionChange?: (selectedId: ItemId | null) => void;
|
|
40
40
|
autoFitOnResize?: boolean; // default true
|
|
41
|
+
/** Arrow-key pan + +/- zoom when the canvas root is focused. Default true (WCAG 2.5.7). */
|
|
42
|
+
keyboardPan?: boolean;
|
|
43
|
+
/** Accessible name on the role="application" canvas root. Default 'Canvas'. */
|
|
44
|
+
ariaLabel?: string;
|
|
41
45
|
className?: string;
|
|
42
46
|
/** Consumer chrome (top bar, palette) rendered inside the canvas surface. */
|
|
43
47
|
children?: ReactNode;
|
|
@@ -18,6 +18,8 @@ export interface UsePanZoomArgs {
|
|
|
18
18
|
editMode: boolean;
|
|
19
19
|
/** Re-fit on window resize. Default true. */
|
|
20
20
|
autoFitOnResize?: boolean;
|
|
21
|
+
/** Arrow-key pan + +/- zoom when the canvas root is focused. Default true (WCAG 2.5.7). */
|
|
22
|
+
keyboardPan?: boolean;
|
|
21
23
|
/** Notified whenever the viewport pan/scale changes (controlled mirror). */
|
|
22
24
|
onViewportChange?: (viewport: Viewport) => void;
|
|
23
25
|
/** Escape exits edit mode → parent owns the state flip. */
|
|
@@ -49,6 +51,7 @@ export function usePanZoom(args: UsePanZoomArgs): UsePanZoom {
|
|
|
49
51
|
layout,
|
|
50
52
|
editMode,
|
|
51
53
|
autoFitOnResize = true,
|
|
54
|
+
keyboardPan = true,
|
|
52
55
|
onViewportChange,
|
|
53
56
|
onEditModeChange,
|
|
54
57
|
onSelectionChange,
|
|
@@ -130,7 +133,9 @@ export function usePanZoom(args: UsePanZoomArgs): UsePanZoom {
|
|
|
130
133
|
autoFit(true);
|
|
131
134
|
}, [editMode, autoFit]);
|
|
132
135
|
|
|
133
|
-
// Keyboard — Escape exits edit, Space arms the pan-grab cursor.
|
|
136
|
+
// Keyboard — Escape exits edit, Space arms the pan-grab cursor. When the
|
|
137
|
+
// canvas root itself holds focus, arrow keys pan and +/- zoom: the keyboard
|
|
138
|
+
// alternative to space+drag / pinch-zoom (WCAG 2.5.7 Dragging Movements).
|
|
134
139
|
useEffect(() => {
|
|
135
140
|
const down = (e: KeyboardEvent) => {
|
|
136
141
|
if (e.key === 'Escape' && editMode) {
|
|
@@ -147,6 +152,42 @@ export function usePanZoom(args: UsePanZoomArgs): UsePanZoom {
|
|
|
147
152
|
canvasRef.current.style.cursor = 'grab';
|
|
148
153
|
e.preventDefault();
|
|
149
154
|
}
|
|
155
|
+
// Arrow-pan / +/- zoom only when the canvas surface itself is focused,
|
|
156
|
+
// so widget-internal controls keep their own arrow-key semantics.
|
|
157
|
+
if (keyboardPan && canvasRef.current && document.activeElement === canvasRef.current) {
|
|
158
|
+
// 24 canvas units per step, scaled so the on-screen nudge feels
|
|
159
|
+
// consistent at any zoom level.
|
|
160
|
+
const PAN_STEP = 24;
|
|
161
|
+
const ZOOM_STEP = 0.1;
|
|
162
|
+
switch (e.key) {
|
|
163
|
+
case 'ArrowUp':
|
|
164
|
+
setPan((p) => ({ ...p, y: p.y + PAN_STEP * p.scale }));
|
|
165
|
+
e.preventDefault();
|
|
166
|
+
break;
|
|
167
|
+
case 'ArrowDown':
|
|
168
|
+
setPan((p) => ({ ...p, y: p.y - PAN_STEP * p.scale }));
|
|
169
|
+
e.preventDefault();
|
|
170
|
+
break;
|
|
171
|
+
case 'ArrowLeft':
|
|
172
|
+
setPan((p) => ({ ...p, x: p.x + PAN_STEP * p.scale }));
|
|
173
|
+
e.preventDefault();
|
|
174
|
+
break;
|
|
175
|
+
case 'ArrowRight':
|
|
176
|
+
setPan((p) => ({ ...p, x: p.x - PAN_STEP * p.scale }));
|
|
177
|
+
e.preventDefault();
|
|
178
|
+
break;
|
|
179
|
+
default:
|
|
180
|
+
// '=' shares the key with '+'; both Equal and NumpadAdd zoom in.
|
|
181
|
+
if (e.code === 'Equal' || e.code === 'NumpadAdd') {
|
|
182
|
+
setPan((p) => ({ ...p, scale: Math.min(2, p.scale + ZOOM_STEP) }));
|
|
183
|
+
e.preventDefault();
|
|
184
|
+
} else if (e.code === 'Minus' || e.code === 'NumpadSubtract') {
|
|
185
|
+
setPan((p) => ({ ...p, scale: Math.max(0.2, p.scale - ZOOM_STEP) }));
|
|
186
|
+
e.preventDefault();
|
|
187
|
+
}
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
150
191
|
};
|
|
151
192
|
const up = (e: KeyboardEvent) => {
|
|
152
193
|
if (e.code === 'Space') {
|
|
@@ -160,7 +201,7 @@ export function usePanZoom(args: UsePanZoomArgs): UsePanZoom {
|
|
|
160
201
|
window.removeEventListener('keydown', down);
|
|
161
202
|
window.removeEventListener('keyup', up);
|
|
162
203
|
};
|
|
163
|
-
}, [editMode, onEditModeChange, onSelectionChange]);
|
|
204
|
+
}, [editMode, keyboardPan, onEditModeChange, onSelectionChange]);
|
|
164
205
|
|
|
165
206
|
const beginPan = useCallback((clientX: number, clientY: number) => {
|
|
166
207
|
const p = panRef.current;
|
package/src/index.ts
CHANGED
package/src/manifest.test.ts
CHANGED
|
@@ -2,9 +2,14 @@ import { test } from 'node:test';
|
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
+
HttpCapabilitySchema,
|
|
6
|
+
IKENGA_API_VERSION,
|
|
7
|
+
InvokeCapabilitySchema,
|
|
5
8
|
ManifestSchema,
|
|
9
|
+
NamedSecretSchema,
|
|
6
10
|
RequiresEntrySchema,
|
|
7
11
|
RequireSourceSchema,
|
|
12
|
+
SecretsCapabilitySchema,
|
|
8
13
|
} from './manifest.js';
|
|
9
14
|
|
|
10
15
|
// ─── WP-11 — `requires` field (ADR-015 §3) ──────────────────────────────────
|
|
@@ -82,6 +87,96 @@ test('Manifest: requires defaults to [] when absent (pre-Phase-4 manifest)', ()
|
|
|
82
87
|
assert.deepEqual(m.requires, []);
|
|
83
88
|
});
|
|
84
89
|
|
|
90
|
+
// ─── WP-01 — trusted-cap tier (ADR-017): http / secrets / invoke + signature ─
|
|
91
|
+
|
|
92
|
+
test('IKENGA_API_VERSION is 3 (ADR-017 soft bump)', () => {
|
|
93
|
+
assert.equal(IKENGA_API_VERSION, 3);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('HttpCapability: auth_header defaults to Authorization; auth_secret optional', () => {
|
|
97
|
+
const h = HttpCapabilitySchema.parse({});
|
|
98
|
+
assert.equal(h.auth_header, 'Authorization');
|
|
99
|
+
assert.equal(h.auth_secret, undefined);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('HttpCapability: .strict rejects unknown field (mirrors Rust deny_unknown_fields)', () => {
|
|
103
|
+
assert.throws(() => HttpCapabilitySchema.parse({ bogus: true }));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('NamedSecret: full shape parses; required defaults false', () => {
|
|
107
|
+
const s = NamedSecretSchema.parse({ name: 'twenty', vault_key: 'TWENTY_API_KEY' });
|
|
108
|
+
assert.equal(s.required, false);
|
|
109
|
+
const r = NamedSecretSchema.parse({
|
|
110
|
+
name: 'k',
|
|
111
|
+
vault_key: 'K',
|
|
112
|
+
required: true,
|
|
113
|
+
format: 'bearer',
|
|
114
|
+
});
|
|
115
|
+
assert.equal(r.required, true);
|
|
116
|
+
assert.equal(r.format, 'bearer');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('NamedSecret: .strict rejects unknown field', () => {
|
|
120
|
+
assert.throws(() =>
|
|
121
|
+
NamedSecretSchema.parse({ name: 'k', vault_key: 'K', bogus: true }),
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('SecretsCapability: declarations default to []', () => {
|
|
126
|
+
const s = SecretsCapabilitySchema.parse({});
|
|
127
|
+
assert.deepEqual(s.declarations, []);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('InvokeCapability: empty object parses; commands defaults to [] (presence gate)', () => {
|
|
131
|
+
assert.deepEqual(InvokeCapabilitySchema.parse({}), { commands: [] });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('InvokeCapability (D-06): commands allowlist parses + survives', () => {
|
|
135
|
+
const i = InvokeCapabilitySchema.parse({ commands: ['pa_actions_commit', 'pa_actions_reject'] });
|
|
136
|
+
assert.deepEqual(i.commands, ['pa_actions_commit', 'pa_actions_reject']);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('InvokeCapability: .strict rejects unknown field (mirrors Rust deny_unknown_fields)', () => {
|
|
140
|
+
assert.throws(() => InvokeCapabilitySchema.parse({ commands: [], bogus: true }));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('Manifest: fully-populated trusted-cap manifest round-trips (G-MANIFEST DoD)', () => {
|
|
144
|
+
// The contract-side half of the round-trip parse-fixture: a manifest carrying
|
|
145
|
+
// ALL FOUR new fields — top-level `signature`, `capabilities.http` (with
|
|
146
|
+
// auth_secret + custom header), `capabilities.secrets` (with a declaration),
|
|
147
|
+
// and the presence-gate `capabilities.invoke` — parses and the values survive.
|
|
148
|
+
const m = ManifestSchema.parse({
|
|
149
|
+
...BASE,
|
|
150
|
+
ikenga_api: '3',
|
|
151
|
+
signature: 'ed25519:Zm9vYmFyYmF6',
|
|
152
|
+
permissions: { net: ['https://api.twenty.com/'], 'vault.keys': ['TWENTY_API_KEY'] },
|
|
153
|
+
capabilities: {
|
|
154
|
+
http: { auth_secret: 'twenty', auth_header: 'X-Api-Key' },
|
|
155
|
+
secrets: {
|
|
156
|
+
declarations: [
|
|
157
|
+
{ name: 'twenty', vault_key: 'TWENTY_API_KEY', required: true, format: 'bearer' },
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
invoke: { commands: ['pa_actions_commit'] },
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
assert.equal(m.signature, 'ed25519:Zm9vYmFyYmF6');
|
|
164
|
+
assert.equal(m.capabilities?.http?.auth_secret, 'twenty');
|
|
165
|
+
assert.equal(m.capabilities?.http?.auth_header, 'X-Api-Key');
|
|
166
|
+
assert.equal(m.capabilities?.secrets?.declarations.length, 1);
|
|
167
|
+
assert.equal(m.capabilities?.secrets?.declarations[0].vault_key, 'TWENTY_API_KEY');
|
|
168
|
+
assert.equal(m.capabilities?.secrets?.declarations[0].required, true);
|
|
169
|
+
assert.ok(m.capabilities?.invoke !== undefined);
|
|
170
|
+
// D-06: the invoke command allowlist survives the round-trip.
|
|
171
|
+
assert.deepEqual(m.capabilities?.invoke?.commands, ['pa_actions_commit']);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('Manifest: api=1 manifest without new fields parses (back-compat)', () => {
|
|
175
|
+
const m = ManifestSchema.parse({ ...BASE });
|
|
176
|
+
assert.equal(m.signature, undefined);
|
|
177
|
+
assert.equal(m.capabilities, undefined);
|
|
178
|
+
});
|
|
179
|
+
|
|
85
180
|
test('Manifest: retired bundling fields are no longer part of the type (WP-17)', () => {
|
|
86
181
|
// ADR-015 decision 4: `skills`/`commands`/`agents` were hard-retired from the
|
|
87
182
|
// schema (lockstep with the Rust `deny_unknown_fields` Manifest, which REJECTS
|
package/src/manifest.ts
CHANGED
|
@@ -10,10 +10,14 @@
|
|
|
10
10
|
|
|
11
11
|
import { z } from 'zod';
|
|
12
12
|
import { EngineProvidesSchema } from './engine/index.js';
|
|
13
|
+
import { BrowserEngineSchema } from './browser.js';
|
|
13
14
|
|
|
14
15
|
// v2 (WP-05): added capabilities.sqlite + permissions["sqlite.tables"];
|
|
15
16
|
// permissions["supabase.tables"] kept as a compat alias for api=1 manifests.
|
|
16
|
-
|
|
17
|
+
// v3 (ADR-017): added capabilities.http / .secrets / .invoke (trusted-cap tier)
|
|
18
|
+
// + top-level optional `signature`. All additive; api=1/2 manifests parse
|
|
19
|
+
// unchanged. Elevated caps are inert unless the pkg is trusted.
|
|
20
|
+
export const IKENGA_API_VERSION = 3 as const;
|
|
17
21
|
export const IKENGA_API_MIN_SUPPORTED = 1 as const;
|
|
18
22
|
|
|
19
23
|
// ---------- Sub-schemas ----------
|
|
@@ -170,6 +174,11 @@ export const WebviewCapabilitySchema = z.object({
|
|
|
170
174
|
child_webviews: z.boolean().default(false),
|
|
171
175
|
/** Named cookie/data partitions; empty = the implicit "default" partition. */
|
|
172
176
|
partitions: z.array(z.string()).default([]),
|
|
177
|
+
/** Browser engines this pkg may open panes with. `"webkit"` is the in-shell
|
|
178
|
+
* child-webview; `"chrome"` is Managed mode (installed Chrome over CDP, its
|
|
179
|
+
* own OS window). Defaults to `["webkit"]` so existing manifests are
|
|
180
|
+
* unchanged. Mirrors `engines` in `WebviewCapability` (manifest.rs). */
|
|
181
|
+
engines: z.array(BrowserEngineSchema).default(['webkit']),
|
|
173
182
|
});
|
|
174
183
|
export type WebviewCapability = z.infer<typeof WebviewCapabilitySchema>;
|
|
175
184
|
|
|
@@ -183,6 +192,65 @@ export type WebviewCapability = z.infer<typeof WebviewCapabilitySchema>;
|
|
|
183
192
|
export const AgentOpsCapabilitySchema = z.object({});
|
|
184
193
|
export type AgentOpsCapability = z.infer<typeof AgentOpsCapabilitySchema>;
|
|
185
194
|
|
|
195
|
+
/** host.fetch capability (ADR-017, TRUSTED-only). Host-mediated HTTP proxy;
|
|
196
|
+
* the shell makes the request and attaches auth from Stronghold — the key
|
|
197
|
+
* never enters the iframe. URL allowlist = `permissions.net`. Mirrors
|
|
198
|
+
* `HttpCapability` in `shell/src-tauri/src/pkg/manifest.rs`. */
|
|
199
|
+
export const HttpCapabilitySchema = z
|
|
200
|
+
.object({
|
|
201
|
+
/** Name of a `capabilities.secrets` declaration whose resolved value the
|
|
202
|
+
* shell attaches as the auth header. Omit = unauthenticated proxy. */
|
|
203
|
+
auth_secret: z.string().optional(),
|
|
204
|
+
/** Header name for the auth secret. Default "Authorization". */
|
|
205
|
+
auth_header: z.string().default('Authorization'),
|
|
206
|
+
})
|
|
207
|
+
.strict();
|
|
208
|
+
export type HttpCapability = z.infer<typeof HttpCapabilitySchema>;
|
|
209
|
+
|
|
210
|
+
/** One named-secret declaration. `vault_key` is resolved host-side and never
|
|
211
|
+
* reaches the iframe; the iframe sees only `hostContext.secrets[name]`.
|
|
212
|
+
* Mirrors `NamedSecret` in `shell/src-tauri/src/pkg/manifest.rs`. */
|
|
213
|
+
export const NamedSecretSchema = z
|
|
214
|
+
.object({
|
|
215
|
+
name: z.string(),
|
|
216
|
+
/** Vault key (must be within permissions["vault.keys"]). Host-only. */
|
|
217
|
+
vault_key: z.string(),
|
|
218
|
+
/** When true, mount fails if the key is missing (Supabase `required`). */
|
|
219
|
+
required: z.boolean().default(false),
|
|
220
|
+
/** Optional value-format hint: "jwt" | "bearer" | "raw". String, not enum. */
|
|
221
|
+
format: z.string().optional(),
|
|
222
|
+
})
|
|
223
|
+
.strict();
|
|
224
|
+
export type NamedSecret = z.infer<typeof NamedSecretSchema>;
|
|
225
|
+
|
|
226
|
+
/** Named-secret injection capability (ADR-017, TRUSTED-only). Generalizes the
|
|
227
|
+
* Supabase hostContext handshake. Mirrors `SecretsCapability` in
|
|
228
|
+
* `shell/src-tauri/src/pkg/manifest.rs`. */
|
|
229
|
+
export const SecretsCapabilitySchema = z
|
|
230
|
+
.object({
|
|
231
|
+
declarations: z.array(NamedSecretSchema).default([]),
|
|
232
|
+
})
|
|
233
|
+
.strict();
|
|
234
|
+
export type SecretsCapability = z.infer<typeof SecretsCapabilitySchema>;
|
|
235
|
+
|
|
236
|
+
/** Scoped Tauri invoke passthrough (ADR-017, TRUSTED-only). Presence gates
|
|
237
|
+
* `host.invoke`; `commands` is the named-command allowlist matched (glob) by
|
|
238
|
+
* `permissions_check::check_shell_execute` against the `host.invoke` command.
|
|
239
|
+
*
|
|
240
|
+
* D-06: the allowlist is `invoke`'s OWN field, NOT `permissions["shell.execute"]`.
|
|
241
|
+
* Reusing `shell.execute` would trip `requires_trust` → the pkg only ever reaches
|
|
242
|
+
* user-`Granted`, never `AutoTrusted`, so `is_trusted_for_elevated()` is false and
|
|
243
|
+
* `host.invoke` would always deny. Keeping the allowlist here lets a signed/builtin
|
|
244
|
+
* pkg declare invokable commands while leaving `shell.execute` empty → AutoTrusted →
|
|
245
|
+
* elevated. POLICY: named commands only, never `*` (not a general shell).
|
|
246
|
+
* Mirrors `InvokeCapability` in `shell/src-tauri/src/pkg/manifest.rs`. */
|
|
247
|
+
export const InvokeCapabilitySchema = z
|
|
248
|
+
.object({
|
|
249
|
+
commands: z.array(z.string()).default([]),
|
|
250
|
+
})
|
|
251
|
+
.strict();
|
|
252
|
+
export type InvokeCapability = z.infer<typeof InvokeCapabilitySchema>;
|
|
253
|
+
|
|
186
254
|
export const WindowBlockSchema = z.object({
|
|
187
255
|
label: z.string(),
|
|
188
256
|
url: z.string(),
|
|
@@ -283,6 +351,12 @@ export const ManifestSchema = z.object({
|
|
|
283
351
|
sqlite: SqliteCapabilitySchema.optional(),
|
|
284
352
|
webview: WebviewCapabilitySchema.optional(),
|
|
285
353
|
agentOps: AgentOpsCapabilitySchema.optional(),
|
|
354
|
+
/** host.fetch proxy (ADR-017). Inert unless the pkg is trusted. */
|
|
355
|
+
http: HttpCapabilitySchema.optional(),
|
|
356
|
+
/** Named-secret injection (ADR-017). Inert unless trusted. */
|
|
357
|
+
secrets: SecretsCapabilitySchema.optional(),
|
|
358
|
+
/** host.invoke passthrough (ADR-017). Inert unless trusted. */
|
|
359
|
+
invoke: InvokeCapabilitySchema.optional(),
|
|
286
360
|
}).optional(),
|
|
287
361
|
|
|
288
362
|
/**
|
|
@@ -310,6 +384,13 @@ export const ManifestSchema = z.object({
|
|
|
310
384
|
* (`pkg/manifest.rs`) — keep in lockstep (`deny_unknown_fields`).
|
|
311
385
|
*/
|
|
312
386
|
requires: z.array(RequiresEntrySchema).default([]),
|
|
387
|
+
|
|
388
|
+
/** Optional ed25519 signature over the normalized manifest JSON (sort keys,
|
|
389
|
+
* strip `signature` before signing). Format `"ed25519:<base64>"`. Present
|
|
390
|
+
* on notarized registry pkgs; verified at install against the publisher key
|
|
391
|
+
* the signed registry index named. Absent → pkg isn't trusted (no elevated
|
|
392
|
+
* caps). Mirrors `signature: Option<String>` on the Rust `Manifest`. */
|
|
393
|
+
signature: z.string().optional(),
|
|
313
394
|
});
|
|
314
395
|
|
|
315
396
|
export type Manifest = z.infer<typeof ManifestSchema>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { type ApproveGateMeta, type DraftItem, draftPreview, fromDraftItem } from './pa-actions.js';
|
|
4
|
+
|
|
5
|
+
const META: ApproveGateMeta = {
|
|
6
|
+
actionId: 'com.ikenga.skill-mail/reply',
|
|
7
|
+
actionName: 'Reply',
|
|
8
|
+
agent: 'PA',
|
|
9
|
+
model: 'Opus 4.7',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function item(over: Partial<DraftItem> = {}): DraftItem {
|
|
13
|
+
return {
|
|
14
|
+
id: 'd1',
|
|
15
|
+
recipient: 'Valentim de Carvalho',
|
|
16
|
+
recipientEmail: 'valentim@valentimdc.pt',
|
|
17
|
+
subject: 'Re: Catalog import',
|
|
18
|
+
body: 'Olá Valentim,\n\nRecebido. Vou processar agora.',
|
|
19
|
+
channel: 'smtp',
|
|
20
|
+
senderAddress: 'chinedum@royalti.io',
|
|
21
|
+
fromProvider: 'SMTP · Fastmail',
|
|
22
|
+
scheduledIso: null,
|
|
23
|
+
scheduledLabel: 'today',
|
|
24
|
+
...over,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Anchor everything to LOCAL time so the day-bucketing assertions are
|
|
29
|
+
// timezone-independent (new Date(y, m, d, h) is local-time construction).
|
|
30
|
+
const NOW = new Date(2026, 5, 7, 11, 0, 0).getTime(); // 11:00 local, 2026-06-07
|
|
31
|
+
const TODAY_ISO = new Date(2026, 5, 7, 17, 0, 0).toISOString(); // same local day, future
|
|
32
|
+
const OVERDUE_ISO = new Date(2026, 5, 5, 9, 0, 0).toISOString(); // two days earlier
|
|
33
|
+
|
|
34
|
+
test('fromDraftItem maps producer fields + meta', () => {
|
|
35
|
+
const d = fromDraftItem(item(), META, NOW);
|
|
36
|
+
assert.equal(d.id, 'd1');
|
|
37
|
+
assert.equal(d.agent, 'PA');
|
|
38
|
+
assert.equal(d.model, 'Opus 4.7');
|
|
39
|
+
assert.equal(d.channel, 'smtp');
|
|
40
|
+
assert.equal(d.consequence.channel, 'SMTP');
|
|
41
|
+
assert.equal(d.consequence.target, 'Valentim de Carvalho');
|
|
42
|
+
assert.equal(d.consequence.recipients, 1);
|
|
43
|
+
assert.equal(d.consequence.undoMs, 10_000);
|
|
44
|
+
assert.equal(d.everEdited, false);
|
|
45
|
+
assert.equal(d.cold, false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('fromDraftItem buckets an overdue draft', () => {
|
|
49
|
+
const d = fromDraftItem(item({ scheduledIso: OVERDUE_ISO }), META, NOW);
|
|
50
|
+
assert.equal(d.overdue, true);
|
|
51
|
+
assert.equal(d.timeVariant, 'is-overdue');
|
|
52
|
+
assert.equal(d.section, 'Overdue');
|
|
53
|
+
assert.equal(d.status, 'overdue');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('fromDraftItem buckets a same-day draft as today', () => {
|
|
57
|
+
const d = fromDraftItem(item({ scheduledIso: TODAY_ISO }), META, NOW);
|
|
58
|
+
assert.equal(d.overdue, false);
|
|
59
|
+
assert.equal(d.timeVariant, 'is-today');
|
|
60
|
+
assert.equal(d.section, 'Today');
|
|
61
|
+
assert.equal(d.status, 'awaiting');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('an explicit section overrides time bucketing', () => {
|
|
65
|
+
const d = fromDraftItem(item({ scheduledIso: OVERDUE_ISO, section: 'This week' }), META, NOW);
|
|
66
|
+
assert.equal(d.section, 'This week');
|
|
67
|
+
assert.equal(d.overdue, true); // overdue flag still reflects the clock
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('sequence recipient count flows into the consequence', () => {
|
|
71
|
+
const d = fromDraftItem(
|
|
72
|
+
item({
|
|
73
|
+
recipients: 388,
|
|
74
|
+
sequence: { name: 'L5 Winback', step: 3, total: 5, recipients: 388 },
|
|
75
|
+
}),
|
|
76
|
+
META,
|
|
77
|
+
NOW
|
|
78
|
+
);
|
|
79
|
+
assert.equal(d.consequence.recipients, 388);
|
|
80
|
+
assert.equal(d.sequence?.step, 3);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('undoMs falls back to 10s when meta omits it', () => {
|
|
84
|
+
const d = fromDraftItem(item(), { ...META, undoMs: undefined }, NOW);
|
|
85
|
+
assert.equal(d.consequence.undoMs, 10_000);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('draftPreview collapses whitespace and truncates', () => {
|
|
89
|
+
assert.equal(draftPreview('a b\n\nc'), 'a b c');
|
|
90
|
+
assert.ok(draftPreview('x'.repeat(300)).length <= 160);
|
|
91
|
+
assert.ok(draftPreview('x'.repeat(300)).endsWith('…'));
|
|
92
|
+
});
|