@canonmsg/core 0.6.0 → 0.7.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.
@@ -0,0 +1 @@
1
+ export declare function resolveCanonBaseUrl(input?: string | null): string;
@@ -0,0 +1,9 @@
1
+ import { DEFAULT_BASE_URL } from './constants.js';
2
+ export function resolveCanonBaseUrl(input) {
3
+ if (typeof input !== 'string')
4
+ return DEFAULT_BASE_URL;
5
+ const trimmed = input.trim();
6
+ if (!trimmed)
7
+ return DEFAULT_BASE_URL;
8
+ return trimmed.replace(/\/+$/, '');
9
+ }
@@ -23,8 +23,8 @@ export interface ConfiguredWorkspaceOption extends WorkspaceResolverOption {
23
23
  }
24
24
  export interface SessionWorkspaceConfig {
25
25
  workspaceId?: string;
26
- legacyCwd?: string;
27
26
  model?: string;
27
+ retiredWorkspaceConfig?: boolean;
28
28
  }
29
29
  export declare function normalizeOptionalString(value: unknown): string | undefined;
30
30
  export declare function isEnabledFlag(value: unknown): boolean;
@@ -37,7 +37,7 @@ export declare function resolveConfiguredWorkspaceCwd(input: {
37
37
  workspaceOptions: WorkspaceResolverOption[];
38
38
  config: {
39
39
  workspaceId?: string;
40
- legacyCwd?: string;
40
+ retiredWorkspaceConfig?: boolean;
41
41
  } | null;
42
42
  defaultCwd: string;
43
43
  }): string;
@@ -153,48 +153,43 @@ export function buildConfiguredWorkspaceOptions(primaryCwd, configured) {
153
153
  export function buildPublicWorkspaceOptions(workspaceOptions) {
154
154
  return workspaceOptions.map(({ id, label }) => ({ id, label }));
155
155
  }
156
+ function isRetiredWorkspaceId(value) {
157
+ if (!value)
158
+ return false;
159
+ return value === 'default' || /^workspace-\d+$/.test(value);
160
+ }
156
161
  export function readSessionWorkspaceConfig(raw) {
157
162
  if (!raw || typeof raw !== 'object')
158
163
  return null;
159
164
  const data = raw;
165
+ const rawWorkspaceId = normalizeOptionalString(data.workspaceId);
166
+ const stableWorkspaceId = rawWorkspaceId && !isRetiredWorkspaceId(rawWorkspaceId)
167
+ ? rawWorkspaceId
168
+ : undefined;
169
+ const hasRetiredWorkspaceReference = (normalizeOptionalString(data.cwd) !== undefined
170
+ || isRetiredWorkspaceId(rawWorkspaceId));
160
171
  return {
161
- workspaceId: normalizeOptionalString(data.workspaceId),
162
- legacyCwd: normalizeOptionalString(data.cwd),
172
+ workspaceId: stableWorkspaceId,
163
173
  model: normalizeOptionalString(data.model),
174
+ ...(!stableWorkspaceId && hasRetiredWorkspaceReference
175
+ ? { retiredWorkspaceConfig: true }
176
+ : {}),
164
177
  };
165
178
  }
166
- function findWorkspaceByLegacyCwd(workspaceOptions, legacyCwd) {
167
- if (!legacyCwd)
168
- return undefined;
169
- const resolvedLegacyCwd = resolve(legacyCwd);
170
- return workspaceOptions.find((workspace) => resolve(workspace.cwd) === resolvedLegacyCwd);
171
- }
172
179
  export function resolveConfiguredWorkspaceCwd(input) {
173
180
  const fallbackCwd = input.workspaceOptions[0]?.cwd ?? resolve(input.defaultCwd);
174
181
  if (!input.config)
175
182
  return fallbackCwd;
176
- const legacyWorkspace = findWorkspaceByLegacyCwd(input.workspaceOptions, input.config.legacyCwd);
183
+ if (input.config.retiredWorkspaceConfig) {
184
+ throw new ExecutionEnvironmentError('Session config still references a retired workspace format.', 'This Canon coding session was saved with a retired workspace format. Recreate the session or select a current workspace.');
185
+ }
177
186
  const workspaceId = input.config.workspaceId;
178
187
  if (workspaceId) {
179
188
  const workspace = input.workspaceOptions.find((option) => option.id === workspaceId);
180
189
  if (workspace)
181
190
  return workspace.cwd;
182
- if (workspaceId === 'default') {
183
- return fallbackCwd;
184
- }
185
- const legacyWorkspaceMatch = /^workspace-(\d+)$/.exec(workspaceId);
186
- if (legacyWorkspaceMatch) {
187
- const legacyIndex = Number.parseInt(legacyWorkspaceMatch[1] ?? '', 10) - 1;
188
- if (legacyIndex >= 0 && input.workspaceOptions[legacyIndex]) {
189
- return input.workspaceOptions[legacyIndex].cwd;
190
- }
191
- }
192
- if (legacyWorkspace)
193
- return legacyWorkspace.cwd;
194
191
  throw new ExecutionEnvironmentError(`Workspace ${workspaceId} is not configured on this machine.`, 'The workspace saved for this Canon coding session is no longer configured on this machine.');
195
192
  }
196
- if (legacyWorkspace)
197
- return legacyWorkspace.cwd;
198
193
  return fallbackCwd;
199
194
  }
200
195
  export function buildConversationWorktreeSpec(input) {
package/dist/index.d.ts CHANGED
@@ -25,3 +25,4 @@ export type { ConfiguredWorkspaceOption, ExecutionEnvironmentMode, PreparedExecu
25
25
  export { initRTDBAuth, rtdbWrite, rtdbRead, writeSessionState, clearSessionState, writeTurnState, clearTurnState, } from './rtdb-rest.js';
26
26
  export type { SessionStatePayload, TurnStatePayload } from './rtdb-rest.js';
27
27
  export { DEFAULT_BASE_URL, DEFAULT_STREAM_URL, DEFAULT_RTDB_URL, FIREBASE_WEB_API_KEY } from './constants.js';
28
+ export { resolveCanonBaseUrl } from './base-url.js';
package/dist/index.js CHANGED
@@ -26,3 +26,5 @@ export { buildConfiguredWorkspaceOptions, buildConversationEnvironmentKey, build
26
26
  export { initRTDBAuth, rtdbWrite, rtdbRead, writeSessionState, clearSessionState, writeTurnState, clearTurnState, } from './rtdb-rest.js';
27
27
  // Constants
28
28
  export { DEFAULT_BASE_URL, DEFAULT_STREAM_URL, DEFAULT_RTDB_URL, FIREBASE_WEB_API_KEY } from './constants.js';
29
+ // Base URL resolver
30
+ export { resolveCanonBaseUrl } from './base-url.js';
@@ -50,8 +50,25 @@ export interface TurnStatePayload {
50
50
  '.sv': 'timestamp';
51
51
  };
52
52
  }
53
- /** Must be called once before any RTDB operations. */
54
- export declare function initRTDBAuth(client: CanonClient): void;
53
+ interface RTDBAuthOptions {
54
+ rtdbUrl?: string;
55
+ firebaseApiKey?: string;
56
+ }
57
+ interface RTDBClientHandle {
58
+ read(path: string): Promise<unknown>;
59
+ write(path: string, data: unknown): Promise<void>;
60
+ patch(path: string, data: unknown): Promise<void>;
61
+ remove(path: string): Promise<void>;
62
+ writeSessionState(conversationId: string, agentId: string, state: Omit<SessionStatePayload, 'updatedAt'>): Promise<void>;
63
+ clearSessionState(conversationId: string, agentId: string): Promise<void>;
64
+ writeTurnState(conversationId: string, agentId: string, state: Omit<TurnStatePayload, 'updatedAt'>): Promise<void>;
65
+ clearTurnState(conversationId: string, agentId: string): Promise<void>;
66
+ }
67
+ /**
68
+ * Initializes the default RTDB helper and returns a scoped client for callers
69
+ * that need per-runtime auth and base-url isolation.
70
+ */
71
+ export declare function initRTDBAuth(client: CanonClient, options?: RTDBAuthOptions): RTDBClientHandle;
55
72
  /** Generic RTDB REST write (PUT). */
56
73
  export declare function rtdbWrite(path: string, data: unknown): Promise<void>;
57
74
  /** Generic RTDB REST read (GET). */
@@ -67,3 +84,4 @@ export declare function writeSessionState(conversationId: string, agentId: strin
67
84
  export declare function clearSessionState(conversationId: string, agentId: string): Promise<void>;
68
85
  export declare function writeTurnState(conversationId: string, agentId: string, state: Omit<TurnStatePayload, 'updatedAt'>): Promise<void>;
69
86
  export declare function clearTurnState(conversationId: string, agentId: string): Promise<void>;
87
+ export {};
package/dist/rtdb-rest.js CHANGED
@@ -6,163 +6,208 @@
6
6
  * is exchanged for a Firebase ID token before use with RTDB REST.
7
7
  */
8
8
  import { DEFAULT_RTDB_URL, FIREBASE_WEB_API_KEY } from './constants.js';
9
- const RTDB_BASE = process.env.CANON_RTDB_URL || DEFAULT_RTDB_URL;
10
- const FIREBASE_API_KEY = process.env.CANON_FIREBASE_API_KEY || FIREBASE_WEB_API_KEY;
11
- // ── Token management ──────────────────────────────────────────────────
12
- let cachedIdToken = null;
13
- let idTokenExpiresAt = 0;
14
- let tokenClient = null;
15
- /** Must be called once before any RTDB operations. */
16
- export function initRTDBAuth(client) {
17
- tokenClient = client;
9
+ const DEFAULT_RTDB_BASE = normalizeRTDBBase(process.env.CANON_RTDB_URL || DEFAULT_RTDB_URL);
10
+ const DEFAULT_FIREBASE_API_KEY = process.env.CANON_FIREBASE_API_KEY || FIREBASE_WEB_API_KEY;
11
+ let defaultRTDBClient = null;
12
+ function normalizeRTDBBase(url) {
13
+ return url.replace(/\/+$/, '');
18
14
  }
19
- /**
20
- * Exchange a Firebase custom token for an ID token via the Identity Toolkit API.
21
- */
22
- async function exchangeCustomTokenForIdToken(customToken) {
23
- const url = `https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${FIREBASE_API_KEY}`;
24
- const res = await fetch(url, {
25
- method: 'POST',
26
- headers: { 'Content-Type': 'application/json' },
27
- body: JSON.stringify({ token: customToken, returnSecureToken: true }),
28
- });
29
- if (!res.ok) {
30
- const text = await res.text();
31
- throw new Error(`Token exchange failed (${res.status}): ${text}`);
32
- }
33
- const data = await res.json();
34
- return { idToken: data.idToken, expiresIn: parseInt(data.expiresIn, 10) };
15
+ function normalizeRTDBPath(path) {
16
+ return path.startsWith('/') ? path : `/${path}`;
35
17
  }
36
- /** Get a valid ID token for RTDB REST, refreshing if expired or expiring soon. */
37
- async function getToken() {
38
- // Refresh if missing, expired, or expiring within 5 minutes
39
- if (!cachedIdToken || Date.now() > idTokenExpiresAt - 5 * 60 * 1000) {
40
- if (!tokenClient)
18
+ function createRTDBClientHandle(client, options) {
19
+ const rtdbBase = normalizeRTDBBase(options?.rtdbUrl || DEFAULT_RTDB_BASE);
20
+ const firebaseApiKey = options?.firebaseApiKey || DEFAULT_FIREBASE_API_KEY;
21
+ let cachedIdToken = null;
22
+ let idTokenExpiresAt = 0;
23
+ let refreshPromise = null;
24
+ async function exchangeCustomTokenForIdToken(customToken) {
25
+ const url = `https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${firebaseApiKey}`;
26
+ const res = await fetch(url, {
27
+ method: 'POST',
28
+ headers: { 'Content-Type': 'application/json' },
29
+ body: JSON.stringify({ token: customToken, returnSecureToken: true }),
30
+ });
31
+ if (!res.ok) {
32
+ const text = await res.text();
33
+ throw new Error(`Token exchange failed (${res.status}): ${text}`);
34
+ }
35
+ const data = await res.json();
36
+ return {
37
+ idToken: data.idToken,
38
+ expiresIn: Number.parseInt(data.expiresIn, 10),
39
+ };
40
+ }
41
+ async function getToken(forceRefresh = false) {
42
+ const now = Date.now();
43
+ if (!forceRefresh && cachedIdToken && now <= idTokenExpiresAt - 5 * 60 * 1000) {
44
+ return cachedIdToken;
45
+ }
46
+ if (refreshPromise) {
47
+ return refreshPromise;
48
+ }
49
+ const staleToken = forceRefresh ? null : cachedIdToken;
50
+ refreshPromise = (async () => {
51
+ try {
52
+ const auth = await client.getAuthToken();
53
+ const { idToken, expiresIn } = await exchangeCustomTokenForIdToken(auth.token);
54
+ cachedIdToken = idToken;
55
+ idTokenExpiresAt = Date.now() + (expiresIn * 1000);
56
+ return cachedIdToken;
57
+ }
58
+ catch (err) {
59
+ console.error('[canon] RTDB token refresh failed:', err);
60
+ return staleToken;
61
+ }
62
+ finally {
63
+ refreshPromise = null;
64
+ }
65
+ })();
66
+ return refreshPromise;
67
+ }
68
+ async function request(method, path, data, options = {}) {
69
+ const token = await getToken(false);
70
+ if (!token)
41
71
  return null;
72
+ const url = `${rtdbBase}${normalizeRTDBPath(path)}.json?auth=${encodeURIComponent(token)}`;
73
+ const res = await fetch(url, {
74
+ method,
75
+ ...(method === 'GET'
76
+ ? {}
77
+ : {
78
+ headers: { 'Content-Type': 'application/json' },
79
+ ...(data === undefined ? {} : { body: JSON.stringify(data) }),
80
+ }),
81
+ });
82
+ if (options.retryUnauthorized !== false
83
+ && (res.status === 401 || res.status === 403)) {
84
+ cachedIdToken = null;
85
+ idTokenExpiresAt = 0;
86
+ const refreshedToken = await getToken(true);
87
+ if (!refreshedToken) {
88
+ return res;
89
+ }
90
+ const retryUrl = `${rtdbBase}${normalizeRTDBPath(path)}.json?auth=${encodeURIComponent(refreshedToken)}`;
91
+ return fetch(retryUrl, {
92
+ method,
93
+ ...(method === 'GET'
94
+ ? {}
95
+ : {
96
+ headers: { 'Content-Type': 'application/json' },
97
+ ...(data === undefined ? {} : { body: JSON.stringify(data) }),
98
+ }),
99
+ });
100
+ }
101
+ return res;
102
+ }
103
+ async function requireSuccess(method, path, data, errorPrefix) {
104
+ const res = await request(method, path, data);
105
+ if (!res)
106
+ return;
107
+ if (!res.ok) {
108
+ const text = await res.text();
109
+ throw new Error(`${errorPrefix} (${res.status}): ${text}`);
110
+ }
111
+ }
112
+ async function read(path) {
113
+ const res = await request('GET', path);
114
+ if (!res?.ok)
115
+ return null;
116
+ return res.json();
117
+ }
118
+ async function write(path, data) {
119
+ await requireSuccess('PUT', path, data, 'RTDB write failed');
120
+ }
121
+ async function patch(path, data) {
122
+ await requireSuccess('PATCH', path, data, 'RTDB patch failed');
123
+ }
124
+ async function remove(path) {
125
+ await requireSuccess('DELETE', path, undefined, 'RTDB delete failed');
126
+ }
127
+ async function writeSessionStateImpl(conversationId, agentId, state) {
128
+ await write(`/session-state/${conversationId}/${agentId}`, {
129
+ ...state,
130
+ updatedAt: { '.sv': 'timestamp' },
131
+ });
132
+ }
133
+ async function clearSessionStateImpl(conversationId, agentId) {
42
134
  try {
43
- const auth = await tokenClient.getAuthToken();
44
- const { idToken, expiresIn } = await exchangeCustomTokenForIdToken(auth.token);
45
- cachedIdToken = idToken;
46
- idTokenExpiresAt = Date.now() + expiresIn * 1000;
135
+ await write(`/session-state/${conversationId}/${agentId}`, {
136
+ isActive: false,
137
+ updatedAt: { '.sv': 'timestamp' },
138
+ });
47
139
  }
48
- catch (err) {
49
- console.error('[canon] RTDB token refresh failed:', err);
50
- return cachedIdToken; // Return stale token as fallback
140
+ catch (error) {
141
+ console.error('[canon] RTDB clear failed:', error);
51
142
  }
52
143
  }
53
- return cachedIdToken;
144
+ async function writeTurnStateImpl(conversationId, agentId, state) {
145
+ await write(`/turn-state/${conversationId}/${agentId}`, {
146
+ ...state,
147
+ updatedAt: { '.sv': 'timestamp' },
148
+ });
149
+ }
150
+ async function clearTurnStateImpl(conversationId, agentId) {
151
+ try {
152
+ await write(`/turn-state/${conversationId}/${agentId}`, {
153
+ state: 'idle',
154
+ queueDepth: 0,
155
+ updatedAt: { '.sv': 'timestamp' },
156
+ completedAt: { '.sv': 'timestamp' },
157
+ });
158
+ }
159
+ catch (error) {
160
+ console.error('[canon] RTDB turn clear failed:', error);
161
+ }
162
+ }
163
+ return {
164
+ read,
165
+ write,
166
+ patch,
167
+ remove,
168
+ writeSessionState: writeSessionStateImpl,
169
+ clearSessionState: clearSessionStateImpl,
170
+ writeTurnState: writeTurnStateImpl,
171
+ clearTurnState: clearTurnStateImpl,
172
+ };
173
+ }
174
+ /**
175
+ * Initializes the default RTDB helper and returns a scoped client for callers
176
+ * that need per-runtime auth and base-url isolation.
177
+ */
178
+ export function initRTDBAuth(client, options) {
179
+ const scopedClient = createRTDBClientHandle(client, options);
180
+ defaultRTDBClient = scopedClient;
181
+ return scopedClient;
182
+ }
183
+ function getDefaultRTDBClient() {
184
+ return defaultRTDBClient;
54
185
  }
55
186
  // ── RTDB operations ───────────────────────────────────────────────────
56
187
  /** Generic RTDB REST write (PUT). */
57
188
  export async function rtdbWrite(path, data) {
58
- const token = await getToken();
59
- if (!token)
60
- return;
61
- const url = `${RTDB_BASE}${path}.json?auth=${token}`;
62
- const res = await fetch(url, {
63
- method: 'PUT',
64
- headers: { 'Content-Type': 'application/json' },
65
- body: JSON.stringify(data),
66
- });
67
- if (!res.ok) {
68
- const text = await res.text();
69
- throw new Error(`RTDB write failed (${res.status}): ${text}`);
70
- }
189
+ await getDefaultRTDBClient()?.write(path, data);
71
190
  }
72
191
  /** Generic RTDB REST read (GET). */
73
192
  export async function rtdbRead(path) {
74
- const token = await getToken();
75
- if (!token)
76
- return null;
77
- const url = `${RTDB_BASE}${path}.json?auth=${token}`;
78
- const res = await fetch(url);
79
- if (!res.ok)
80
- return null;
81
- return res.json();
193
+ return getDefaultRTDBClient()?.read(path) ?? null;
82
194
  }
83
195
  /**
84
196
  * Write session state to RTDB via REST API.
85
197
  * Path: /session-state/{conversationId}/{agentId}
86
198
  */
87
199
  export async function writeSessionState(conversationId, agentId, state) {
88
- const token = await getToken();
89
- if (!token)
90
- return;
91
- const url = `${RTDB_BASE}/session-state/${conversationId}/${agentId}.json?auth=${token}`;
92
- const body = {
93
- ...state,
94
- updatedAt: { '.sv': 'timestamp' },
95
- };
96
- const res = await fetch(url, {
97
- method: 'PUT',
98
- headers: { 'Content-Type': 'application/json' },
99
- body: JSON.stringify(body),
100
- });
101
- if (!res.ok) {
102
- const text = await res.text();
103
- throw new Error(`RTDB write failed (${res.status}): ${text}`);
104
- }
200
+ await getDefaultRTDBClient()?.writeSessionState(conversationId, agentId, state);
105
201
  }
106
202
  /**
107
203
  * Clear session state from RTDB (full overwrite with isActive: false).
108
204
  */
109
205
  export async function clearSessionState(conversationId, agentId) {
110
- const token = await getToken();
111
- if (!token)
112
- return;
113
- const url = `${RTDB_BASE}/session-state/${conversationId}/${agentId}.json?auth=${token}`;
114
- const body = {
115
- isActive: false,
116
- updatedAt: { '.sv': 'timestamp' },
117
- };
118
- // Use PUT (not PATCH) to clear stale fields like model/cwd
119
- const res = await fetch(url, {
120
- method: 'PUT',
121
- headers: { 'Content-Type': 'application/json' },
122
- body: JSON.stringify(body),
123
- });
124
- if (!res.ok) {
125
- const text = await res.text();
126
- console.error(`[canon] RTDB clear failed (${res.status}): ${text}`);
127
- }
206
+ await getDefaultRTDBClient()?.clearSessionState(conversationId, agentId);
128
207
  }
129
208
  export async function writeTurnState(conversationId, agentId, state) {
130
- const token = await getToken();
131
- if (!token)
132
- return;
133
- const url = `${RTDB_BASE}/turn-state/${conversationId}/${agentId}.json?auth=${token}`;
134
- const body = {
135
- ...state,
136
- updatedAt: { '.sv': 'timestamp' },
137
- };
138
- const res = await fetch(url, {
139
- method: 'PUT',
140
- headers: { 'Content-Type': 'application/json' },
141
- body: JSON.stringify(body),
142
- });
143
- if (!res.ok) {
144
- const text = await res.text();
145
- throw new Error(`RTDB write failed (${res.status}): ${text}`);
146
- }
209
+ await getDefaultRTDBClient()?.writeTurnState(conversationId, agentId, state);
147
210
  }
148
211
  export async function clearTurnState(conversationId, agentId) {
149
- const token = await getToken();
150
- if (!token)
151
- return;
152
- const url = `${RTDB_BASE}/turn-state/${conversationId}/${agentId}.json?auth=${token}`;
153
- const body = {
154
- state: 'idle',
155
- queueDepth: 0,
156
- updatedAt: { '.sv': 'timestamp' },
157
- completedAt: { '.sv': 'timestamp' },
158
- };
159
- const res = await fetch(url, {
160
- method: 'PUT',
161
- headers: { 'Content-Type': 'application/json' },
162
- body: JSON.stringify(body),
163
- });
164
- if (!res.ok) {
165
- const text = await res.text();
166
- console.error(`[canon] RTDB turn clear failed (${res.status}): ${text}`);
167
- }
212
+ await getDefaultRTDBClient()?.clearTurnState(conversationId, agentId);
168
213
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/core",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Canon core — shared types, REST client, SSE stream, and registration for Canon messaging",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -8,10 +8,12 @@
8
8
  "exports": {
9
9
  ".": {
10
10
  "import": "./dist/index.js",
11
+ "default": "./dist/index.js",
11
12
  "types": "./dist/index.d.ts"
12
13
  },
13
14
  "./browser": {
14
15
  "import": "./dist/browser.js",
16
+ "default": "./dist/browser.js",
15
17
  "types": "./dist/browser.d.ts"
16
18
  }
17
19
  },