@ikenga/contract 0.3.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/src/rpc.ts ADDED
@@ -0,0 +1,97 @@
1
+ // Shell ↔ pkg RPC. Same envelope on both transports:
2
+ // - iframe pkgs: postMessage over MessageChannel
3
+ // - mounted pkgs: direct function call on the host bus
4
+ //
5
+ // Methods are namespaced by capability area. The kernel enforces scopes
6
+ // declared in the pkg manifest before dispatching.
7
+
8
+ export const CONTRACT_VERSION = 1 as const;
9
+
10
+ // ---------- Envelope ----------
11
+
12
+ export interface RpcRequest<TParams = unknown> {
13
+ v: typeof CONTRACT_VERSION;
14
+ id: string;
15
+ method: string;
16
+ params: TParams;
17
+ }
18
+
19
+ export interface RpcResponseOk<TResult = unknown> {
20
+ v: typeof CONTRACT_VERSION;
21
+ id: string;
22
+ result: TResult;
23
+ }
24
+
25
+ export interface RpcResponseErr {
26
+ v: typeof CONTRACT_VERSION;
27
+ id: string;
28
+ error: { code: RpcErrorCode; message: string; data?: unknown };
29
+ }
30
+
31
+ export type RpcResponse<T = unknown> = RpcResponseOk<T> | RpcResponseErr;
32
+ export type RpcMessage = RpcRequest | RpcResponse;
33
+
34
+ export type RpcErrorCode =
35
+ | 'method_not_found'
36
+ | 'invalid_params'
37
+ | 'scope_denied'
38
+ | 'pkg_not_authenticated'
39
+ | 'shell_unavailable'
40
+ | 'internal_error';
41
+
42
+ // ---------- Notifications (one-way, pkg → shell or shell → pkg) ----------
43
+
44
+ export interface RpcNotification<TParams = unknown> {
45
+ v: typeof CONTRACT_VERSION;
46
+ notify: string;
47
+ params: TParams;
48
+ }
49
+
50
+ // ---------- Method catalogue ----------
51
+ //
52
+ // Adding a method is a contract change. Removals require a deprecation
53
+ // period across two contract minor versions.
54
+
55
+ export interface RpcMethods {
56
+ // identity
57
+ 'identity.whoami': { req: void; res: { user_id: string; tenant_id: string | null } };
58
+
59
+ // pkg → engine
60
+ 'engine.start_session': {
61
+ req: { systemPrompt?: string; toolAllowList?: string[] };
62
+ res: { sessionId: string };
63
+ };
64
+ 'engine.stream': {
65
+ req: { sessionId: string; input: string };
66
+ res: { ok: true }; // events delivered via 'engine.event' notifications
67
+ };
68
+ 'engine.cancel': { req: { sessionId: string }; res: { ok: true } };
69
+
70
+ // shell chrome
71
+ 'shell.notify': { req: { title: string; body?: string; level?: 'info' | 'warn' | 'error' }; res: void };
72
+ 'shell.open_pane': {
73
+ req: { route?: string; pkg_id?: string; split?: 'horizontal' | 'vertical' };
74
+ res: { pane_id: string };
75
+ };
76
+
77
+ // tasks (capability: tasks:read / tasks:write)
78
+ 'tasks.list': { req: { status?: string; owner?: string }; res: unknown[] };
79
+ 'tasks.create': { req: { subject: string; description?: string }; res: { id: string } };
80
+
81
+ // email drafts
82
+ 'email_drafts.list': { req: { status?: string }; res: unknown[] };
83
+ 'email_drafts.create': { req: { subject: string; body_html: string; to: string[] }; res: { id: string } };
84
+ }
85
+
86
+ // Notifications: shell → pkg
87
+ export interface ShellToPkgNotifications {
88
+ 'engine.event': { sessionId: string; event: import('./engine.js').EngineEvent };
89
+ 'shell.theme_changed': { theme: 'light' | 'dark' };
90
+ 'shell.pane_focused': { focused: boolean };
91
+ }
92
+
93
+ // ---------- Helpers ----------
94
+
95
+ export type RpcMethodName = keyof RpcMethods;
96
+ export type RpcParams<M extends RpcMethodName> = RpcMethods[M]['req'];
97
+ export type RpcResult<M extends RpcMethodName> = RpcMethods[M]['res'];
package/src/scopes.ts ADDED
@@ -0,0 +1,71 @@
1
+ // Capability scopes a pkg can request in its manifest. The kernel enforces
2
+ // these at the IPC boundary — scope-denied requests fail with
3
+ // `RpcErrorCode.scope_denied` before reaching the handler.
4
+ //
5
+ // Scope syntax: `<resource>:<action>` or `<resource>:<action>:<qualifier>`
6
+ //
7
+ // Pattern wildcards are allowed in qualifiers only:
8
+ // "fs:read:projects/*" ✓ (file under /projects/)
9
+ // "tasks:*" ✗ (action wildcards forbidden — be explicit)
10
+
11
+ export interface ScopeDef {
12
+ scope: string;
13
+ description: string;
14
+ /** Whether this scope requires explicit user consent at install time. */
15
+ sensitive?: boolean;
16
+ }
17
+
18
+ export const SCOPE_CATALOGUE: ScopeDef[] = [
19
+ // identity
20
+ { scope: 'identity:read', description: 'Read the current user / tenant identity.' },
21
+
22
+ // tasks
23
+ { scope: 'tasks:read', description: 'List and read tasks.' },
24
+ { scope: 'tasks:write', description: 'Create, update, complete tasks.' },
25
+
26
+ // contacts
27
+ { scope: 'contacts:read', description: 'Read the contacts directory.' },
28
+ { scope: 'contacts:write', description: 'Create or update contacts.', sensitive: true },
29
+
30
+ // email
31
+ { scope: 'email_drafts:read', description: 'Read drafts in the email queue.' },
32
+ { scope: 'email_drafts:write', description: 'Create or modify drafts.' },
33
+ { scope: 'email:send', description: 'Send email through the shell delivery layer.', sensitive: true },
34
+
35
+ // social
36
+ { scope: 'social:read', description: 'Read queued and posted social items.' },
37
+ { scope: 'social:publish', description: 'Publish to connected social accounts.', sensitive: true },
38
+
39
+ // filesystem (shell-mediated)
40
+ { scope: 'fs:read', description: 'Read files inside the shell-managed sandbox.' },
41
+ { scope: 'fs:write', description: 'Write files inside the shell-managed sandbox.', sensitive: true },
42
+
43
+ // engine
44
+ { scope: 'engine:invoke', description: 'Start sessions and stream from the active AI engine.' },
45
+ { scope: 'engine:register_mcp', description: 'Register additional MCP servers with the engine.', sensitive: true },
46
+
47
+ // shell chrome
48
+ { scope: 'shell:notify', description: 'Show notifications in the shell UI.' },
49
+ { scope: 'shell:open_pane', description: 'Open or split panes in the shell layout.' },
50
+ ];
51
+
52
+ const SCOPE_INDEX = new Map(SCOPE_CATALOGUE.map((s) => [s.scope, s]));
53
+
54
+ export function isKnownScope(scope: string): boolean {
55
+ if (SCOPE_INDEX.has(scope)) return true;
56
+ // Allow qualified variants (`fs:read:projects/*`) only if the unqualified
57
+ // form is in the catalogue.
58
+ const parts = scope.split(':');
59
+ if (parts.length < 3) return false;
60
+ return SCOPE_INDEX.has(`${parts[0]}:${parts[1]}`);
61
+ }
62
+
63
+ export function isSensitiveScope(scope: string): boolean {
64
+ const def = SCOPE_INDEX.get(scope);
65
+ if (def) return !!def.sensitive;
66
+ const parts = scope.split(':');
67
+ if (parts.length < 3) return false;
68
+ return !!SCOPE_INDEX.get(`${parts[0]}:${parts[1]}`)?.sensitive;
69
+ }
70
+
71
+ export type Scope = string;