@atlastodo/sdk 0.1.11-rc.7 → 0.1.11-rc.9

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/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./client.js";
2
+ export * from "./vault.js";
2
3
  export { InvalidApiTokenError } from "@atlastodo/client-core";
3
4
  export * from "./dates.js";
package/dist/index.js CHANGED
@@ -310,6 +310,219 @@ function checkPriority(p) {
310
310
  function lockedError(id) {
311
311
  return new AtlasError("task_locked", `task "${id}" is in a shared project whose key this token cannot open`);
312
312
  }
313
+ // src/vault.ts
314
+ import {
315
+ ApiClient as ApiClient2,
316
+ Keyring as Keyring2,
317
+ LocalStore as LocalStore2,
318
+ SyncClient as SyncClient2,
319
+ assertServerCompatible,
320
+ deriveApiTokenKeys as deriveApiTokenKeys2,
321
+ getPublicKey,
322
+ parseApiToken as parseApiToken2,
323
+ unwrapApiTokenAppKeys
324
+ } from "@atlastodo/client-core";
325
+ import {
326
+ VAULT_ITEM_TYPES,
327
+ VaultError,
328
+ newVaultItemFields,
329
+ resolveVaultItem,
330
+ summarizeVaultItem,
331
+ toVaultItem,
332
+ vaultItemChanges,
333
+ vaultItemField
334
+ } from "@atlastodo/shared";
335
+ var VAULT_APP = "vault";
336
+ var sameName = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
337
+ function byNameThenId(a, b) {
338
+ const [an, bn] = [a.name.toLowerCase(), b.name.toLowerCase()];
339
+ if (an !== bn)
340
+ return an < bn ? -1 : 1;
341
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
342
+ }
343
+ function rules(run) {
344
+ try {
345
+ return run();
346
+ } catch (e) {
347
+ if (e instanceof VaultError)
348
+ throw new AtlasError(e.code, e.message);
349
+ throw e;
350
+ }
351
+ }
352
+
353
+ class VaultClient {
354
+ store;
355
+ sync;
356
+ userId;
357
+ canWrite;
358
+ expiresAt;
359
+ constructor(store, sync, userId, canWrite, expiresAt) {
360
+ this.store = store;
361
+ this.sync = sync;
362
+ this.userId = userId;
363
+ this.canWrite = canWrite;
364
+ this.expiresAt = expiresAt;
365
+ }
366
+ static async connect(opts) {
367
+ const parts = parseApiToken2(opts.token);
368
+ const { auth } = deriveApiTokenKeys2(parts);
369
+ const api = new ApiClient2({
370
+ baseUrl: opts.serverUrl.replace(/\/+$/, ""),
371
+ fetch: opts.fetch,
372
+ apps: [VAULT_APP],
373
+ reauthenticate: async () => (await api.openApiTokenSession(parts.id, auth)).access_token
374
+ });
375
+ if (opts.checkServer ?? true) {
376
+ assertServerCompatible(await api.getDiscovery(), {
377
+ minProtocol: 6,
378
+ capabilities: ["api_tokens", "app_scoped_sync", "app_scoped_api_tokens"],
379
+ apps: [VAULT_APP]
380
+ });
381
+ }
382
+ const session = await api.openApiTokenSession(parts.id, auth);
383
+ const apps = session.apps ?? ["todo"];
384
+ const wraps = session.app_keys?.[VAULT_APP];
385
+ if (!apps.includes(VAULT_APP) || !wraps) {
386
+ throw new AtlasError("app_not_in_token", `this API token is not for Atlas Vault (it is for ${apps.join(", ")}); make a token that includes it`);
387
+ }
388
+ api.setTokens(session.access_token);
389
+ const { appKey, privateKey } = unwrapApiTokenAppKeys(parts, session.user_id, VAULT_APP, wraps);
390
+ api.setKeyring(new Keyring2({ dek: appKey, privateKey, publicKey: getPublicKey(privateKey) }));
391
+ api.setLegacyMigrated(true);
392
+ const store = new LocalStore2(parts.id, { newId: () => globalThis.crypto.randomUUID() });
393
+ api.setScopeContext(store, session.user_id);
394
+ const sync = new SyncClient2(store, api, { cursor: 0 });
395
+ const client = new VaultClient(store, sync, session.user_id, session.write ?? true, session.expires_at ?? null);
396
+ await client.syncOrThrow();
397
+ return client;
398
+ }
399
+ async syncOrThrow() {
400
+ const result = await this.sync.sync();
401
+ if (result.skipped) {
402
+ throw new AtlasError("sync_skipped", `sync did not run (${result.skipped}); try again later`);
403
+ }
404
+ }
405
+ async push() {
406
+ await this.syncOrThrow();
407
+ const pending = this.store.unsyncedOps().length;
408
+ if (pending > 0) {
409
+ throw new AtlasError("not_pushed", `${pending} change(s) did not reach the server`);
410
+ }
411
+ }
412
+ requireWrite() {
413
+ if (!this.canWrite) {
414
+ throw new AtlasError("api_token_read_only", "this API token is read-only: changing Atlas Vault data needs a token made with write access");
415
+ }
416
+ }
417
+ allItems() {
418
+ return this.store.list("vault.item").map((e) => toVaultItem(e.id, e.fields));
419
+ }
420
+ folderName(id) {
421
+ const f = id ? this.store.get("vault.folder", id) : null;
422
+ return f && typeof f.name === "string" ? f.name : null;
423
+ }
424
+ view(item) {
425
+ return { ...item, folder_name: this.folderName(item.folder) };
426
+ }
427
+ summaryView(item) {
428
+ return { ...summarizeVaultItem(item), folder_name: this.folderName(item.folder) };
429
+ }
430
+ folders() {
431
+ return this.store.list("vault.folder").map((e) => ({
432
+ id: e.id,
433
+ name: typeof e.fields.name === "string" ? e.fields.name : "",
434
+ created_at: typeof e.fields.created_at === "number" ? e.fields.created_at : 0,
435
+ updated_at: typeof e.fields.updated_at === "number" ? e.fields.updated_at : 0,
436
+ deleted_at: typeof e.fields.deleted_at === "number" ? e.fields.deleted_at : null
437
+ })).filter((f) => f.deleted_at == null).sort(byNameThenId);
438
+ }
439
+ folderId(ref) {
440
+ const r = ref.trim();
441
+ const folders = this.folders();
442
+ const byId = folders.find((f) => f.id === r);
443
+ if (byId)
444
+ return byId.id;
445
+ const named = folders.filter((f) => sameName(f.name, r));
446
+ if (named.length === 1)
447
+ return named[0].id;
448
+ if (named.length === 0)
449
+ throw new AtlasError("unknown_vault_folder", `no vault folder "${r}"`);
450
+ throw new AtlasError("ambiguous_vault_folder", `"${r}" names ${named.length} vault folders; use an id`);
451
+ }
452
+ itemId(ref) {
453
+ return rules(() => resolveVaultItem(this.allItems(), ref));
454
+ }
455
+ item(ref) {
456
+ const id = this.itemId(ref);
457
+ return this.view(toVaultItem(id, this.store.get("vault.item", id) ?? {}));
458
+ }
459
+ secret(ref, field) {
460
+ const item = this.item(ref);
461
+ return rules(() => vaultItemField(item, field));
462
+ }
463
+ items(query = {}) {
464
+ const folder = query.folder ? this.folderId(query.folder) : null;
465
+ if (query.type && !VAULT_ITEM_TYPES.includes(query.type)) {
466
+ throw new AtlasError("bad_input", `type must be one of ${VAULT_ITEM_TYPES.join(", ")}`);
467
+ }
468
+ const tag = query.tag?.toLowerCase();
469
+ const search = query.search?.toLowerCase();
470
+ return this.allItems().filter((i) => i.deleted_at == null).filter((i) => folder == null || i.folder === folder).filter((i) => !query.type || i.type === query.type).filter((i) => !tag || i.tags.some((t) => t.toLowerCase() === tag)).filter((i) => !search || i.name.toLowerCase().includes(search) || i.urls.some((u) => u.toLowerCase().includes(search)) || i.tags.some((t) => t.toLowerCase().includes(search))).sort(byNameThenId).map((i) => this.summaryView(i));
471
+ }
472
+ withFolderId(input) {
473
+ return typeof input.folder === "string" ? { ...input, folder: this.folderId(input.folder) } : input;
474
+ }
475
+ write(entity, id, fields) {
476
+ for (const [field, value] of Object.entries(fields))
477
+ this.store.set(entity, id, field, value);
478
+ }
479
+ async createItem(input) {
480
+ this.requireWrite();
481
+ const fields = rules(() => newVaultItemFields(this.withFolderId(input), Date.now()));
482
+ const id = this.store.newEntityId();
483
+ this.write("vault.item", id, fields);
484
+ await this.push();
485
+ return this.item(id);
486
+ }
487
+ async updateItem(ref, patch) {
488
+ const id = this.itemId(ref);
489
+ this.requireWrite();
490
+ const current = this.store.get("vault.item", id) ?? {};
491
+ const changes = rules(() => vaultItemChanges(current, this.withFolderId(patch), Date.now()));
492
+ if (Object.keys(changes).length) {
493
+ this.write("vault.item", id, changes);
494
+ await this.push();
495
+ }
496
+ return this.item(id);
497
+ }
498
+ async deleteItem(ref) {
499
+ const id = this.itemId(ref);
500
+ if (this.item(id).deleted_at != null)
501
+ return id;
502
+ this.requireWrite();
503
+ const now = Date.now();
504
+ this.write("vault.item", id, { deleted_at: now, updated_at: now });
505
+ await this.push();
506
+ return id;
507
+ }
508
+ async createFolder(name) {
509
+ this.requireWrite();
510
+ const trimmed = name.trim();
511
+ if (!trimmed)
512
+ throw new AtlasError("bad_input", "a vault folder needs a name");
513
+ if (this.folders().some((f) => sameName(f.name, trimmed))) {
514
+ throw new AtlasError("vault_folder_exists", `there is already a vault folder "${trimmed}"`);
515
+ }
516
+ const now = Date.now();
517
+ const id = this.store.newEntityId();
518
+ this.write("vault.folder", id, { name: trimmed, created_at: now, updated_at: now });
519
+ await this.push();
520
+ return this.folders().find((f) => f.id === id);
521
+ }
522
+ close() {
523
+ this.sync.dispose();
524
+ }
525
+ }
313
526
 
314
527
  // src/index.ts
315
528
  import { InvalidApiTokenError } from "@atlastodo/client-core";
@@ -368,5 +581,7 @@ export {
368
581
  AtlasClient,
369
582
  AtlasError,
370
583
  InvalidApiTokenError,
584
+ VAULT_APP,
585
+ VaultClient,
371
586
  parseDue
372
587
  };
package/dist/testing.d.ts CHANGED
@@ -1,11 +1,28 @@
1
1
  import { LocalStore, SyncClient, type FetchLike, type WireOp } from "@atlastodo/client-core";
2
2
  export declare const USER_ID = "01920000-0000-7000-8000-0000000000aa";
3
+ export interface FakeServerOptions {
4
+ /** The apps the token is for; todo by default. */
5
+ apps?: readonly string[];
6
+ /** The apps `GET /discovery` offers; without it the server predates discovery (404). */
7
+ offers?: readonly string[];
8
+ /** Whether the token may write (`write` of the session); a push is 403 when it may not. */
9
+ write?: boolean;
10
+ /** The token's `expires_at` (unix ms) in the session response. */
11
+ expiresAt?: number;
12
+ }
3
13
  /**
4
14
  * A stand-in for the server's sync surface: one account's op log, kept as the opaque wire ops the
5
15
  * clients push (ciphertext), like the real server. Enough of the API for the SDK and an app-like
6
16
  * second client.
17
+ *
18
+ * Its token is for todo unless made for other `apps`, each with a fresh key and identity. Sync
19
+ * requests are scoped as the server scopes a token session (`x-atlas-sync-apps`, todo when
20
+ * absent): an app the token is not for is 403 `app_not_enabled`, pull and the snapshot serve only
21
+ * the scope's ops. A read-only token's push is 403 `api_token_read_only`; once {@link expired},
22
+ * the exchange and every request are 401 `api_token_expired`.
7
23
  */
8
24
  export declare class FakeServer {
25
+ private readonly opts;
9
26
  readonly ops: (WireOp & {
10
27
  seq: number;
11
28
  })[];
@@ -15,8 +32,19 @@ export declare class FakeServer {
15
32
  dek: Uint8Array<ArrayBufferLike> & Uint8Array<ArrayBuffer>;
16
33
  privateKey: Uint8Array<ArrayBufferLike> & Uint8Array<ArrayBuffer>;
17
34
  };
35
+ readonly apps: readonly string[];
36
+ /** Every other app's key and X25519 private key, by app. */
37
+ readonly appKeys: Record<string, {
38
+ appKey: Uint8Array;
39
+ privateKey: Uint8Array;
40
+ }>;
18
41
  sessions: number;
19
42
  revoked: boolean;
43
+ expired: boolean;
44
+ write: boolean;
45
+ /** The `x-atlas-sync-apps` header of every `/sync/*` request (null when absent). */
46
+ readonly syncApps: (string | null)[];
47
+ constructor(opts?: FakeServerOptions);
20
48
  tokenString(): string;
21
49
  fetch: FetchLike;
22
50
  /** A signed-in app device of the same account, for writes the SDK should see. */
package/dist/testing.js CHANGED
@@ -4,21 +4,39 @@ import {
4
4
  LocalStore,
5
5
  SyncClient,
6
6
  Keyring,
7
+ SYNC_APPS_HEADER,
8
+ appOfKind,
7
9
  deriveApiTokenKeys,
8
10
  formatApiToken,
9
11
  generateApiToken,
10
12
  randomBytes,
13
+ wrapAppKeysForApiToken,
11
14
  wrapKeysForApiToken
12
15
  } from "@atlastodo/client-core";
13
16
  var USER_ID = "01920000-0000-7000-8000-0000000000aa";
14
17
 
15
18
  class FakeServer {
19
+ opts;
16
20
  ops = [];
17
21
  seq = 0;
18
22
  token = generateApiToken();
19
23
  keys = { dek: randomBytes(32), privateKey: randomBytes(32) };
24
+ apps;
25
+ appKeys = {};
20
26
  sessions = 0;
21
27
  revoked = false;
28
+ expired = false;
29
+ write;
30
+ syncApps = [];
31
+ constructor(opts = {}) {
32
+ this.opts = opts;
33
+ this.apps = opts.apps ?? ["todo"];
34
+ this.write = opts.write ?? true;
35
+ for (const app of this.apps) {
36
+ if (app !== "todo")
37
+ this.appKeys[app] = { appKey: randomBytes(32), privateKey: randomBytes(32) };
38
+ }
39
+ }
22
40
  tokenString() {
23
41
  return formatApiToken(this.token);
24
42
  }
@@ -26,37 +44,89 @@ class FakeServer {
26
44
  const u = new URL(String(url));
27
45
  const body = init?.body ? JSON.parse(String(init.body)) : undefined;
28
46
  const json = (status, data) => new Response(data === undefined ? null : JSON.stringify(data), { status });
29
- const authed = init?.headers?.authorization;
47
+ const headers = init?.headers ?? {};
48
+ const authed = headers.authorization;
49
+ const expired = () => json(401, { error: "this API token has expired", code: "api_token_expired" });
30
50
  switch (`${init?.method} ${u.pathname}`) {
51
+ case "GET /discovery": {
52
+ if (!this.opts.offers)
53
+ return json(404, { error: "not found" });
54
+ return json(200, {
55
+ service: "atlas-server",
56
+ version: "0.2.0",
57
+ protocol_version: 6,
58
+ min_protocol_version: 6,
59
+ capabilities: [
60
+ "api_tokens",
61
+ "app_manifests",
62
+ "account_apps",
63
+ "root_key",
64
+ "app_scoped_sync",
65
+ "app_scoped_api_tokens",
66
+ "api_token_policies"
67
+ ],
68
+ apps: this.opts.offers.map((id) => ({ id, name: id, version: 1 }))
69
+ });
70
+ }
31
71
  case "POST /auth/api-token/session": {
32
72
  if (this.revoked || body.token_id !== this.token.id || body.auth !== deriveApiTokenKeys(this.token).auth) {
33
73
  return json(401, { error: "unauthorized" });
34
74
  }
75
+ if (this.expired)
76
+ return expired();
35
77
  this.sessions++;
78
+ const appKeys = Object.fromEntries(Object.entries(this.appKeys).map(([app, keys]) => [
79
+ app,
80
+ wrapAppKeysForApiToken(this.token, USER_ID, app, keys)
81
+ ]));
36
82
  return json(200, {
37
83
  access_token: `access-${this.sessions}`,
38
84
  expires_in: 900,
39
85
  token_id: this.token.id,
40
86
  user_id: USER_ID,
41
- ...wrapKeysForApiToken(this.token, USER_ID, this.keys)
87
+ apps: this.apps,
88
+ write: this.write,
89
+ ...this.opts.expiresAt !== undefined ? { expires_at: this.opts.expiresAt } : {},
90
+ ...this.apps.includes("todo") ? wrapKeysForApiToken(this.token, USER_ID, this.keys) : {},
91
+ ...Object.keys(appKeys).length ? { app_keys: appKeys } : {}
42
92
  });
43
93
  }
44
94
  }
45
95
  if (this.revoked || !authed?.startsWith("Bearer "))
46
96
  return json(401, { error: "unauthorized" });
97
+ if (this.expired)
98
+ return expired();
99
+ const scopeHeader = headers[SYNC_APPS_HEADER] ?? null;
100
+ const scope = scopeHeader ? scopeHeader.split(",").map((a) => a.trim()) : ["todo"];
101
+ const inScope = (op) => scope.includes(appOfKind(op.entity)?.app ?? "");
102
+ if (u.pathname.startsWith("/sync/")) {
103
+ this.syncApps.push(scopeHeader);
104
+ const outside = scope.find((a) => !this.apps.includes(a));
105
+ if (outside) {
106
+ return json(403, { code: "app_not_enabled", app: outside, scope: "token", error: "no" });
107
+ }
108
+ }
47
109
  switch (`${init?.method} ${u.pathname}`) {
48
110
  case "GET /sync/snapshot":
49
- return json(200, { operations: this.ops, cursor: this.seq });
111
+ return json(200, { operations: this.ops.filter(inScope), cursor: this.seq });
50
112
  case "GET /sync/pull": {
51
113
  const since = Number(u.searchParams.get("since"));
52
114
  const page = this.ops.filter((o) => o.seq > since).slice(0, 500);
53
115
  return json(200, {
54
- operations: page,
116
+ operations: page.filter(inScope),
55
117
  from: since,
56
118
  cursor: page.length ? page[page.length - 1].seq : Math.max(since, this.seq)
57
119
  });
58
120
  }
59
121
  case "POST /sync/push": {
122
+ if (!this.write) {
123
+ return json(403, { error: "this API token is read-only", code: "api_token_read_only" });
124
+ }
125
+ const outside = body.operations.find((op) => !inScope(op));
126
+ if (outside) {
127
+ const app = appOfKind(outside.entity)?.app ?? "unknown";
128
+ return json(403, { code: "app_not_enabled", app, scope: "session", error: "no" });
129
+ }
60
130
  const from = this.seq;
61
131
  for (const op of body.operations) {
62
132
  if (this.ops.some((o) => o.id === op.id))
@@ -0,0 +1,91 @@
1
+ import { type FetchLike } from "@atlastodo/client-core";
2
+ import { type VaultItem, type VaultItemInput, type VaultItemPatch, type VaultItemSummary } from "@atlastodo/shared";
3
+ /**
4
+ * A headless Atlas Vault client for integrations and CI, signed in with an API token that includes
5
+ * the vault (`apps: ["vault"]`). Like `AtlasClient` for todo, `connect` unwraps the vault key the
6
+ * token carries and pulls the vault into memory, decrypting it here: the server only ever sees
7
+ * ciphertext, and reads none of an item's fields. Every sync request names the vault
8
+ * (`x-atlas-sync-apps`). The item rules are `@atlastodo/shared`'s, at parity with the `atlas`
9
+ * CLI's (the shared vault item test vectors).
10
+ *
11
+ * A vault token is read-only unless it was made with write access, and always expires; a write on
12
+ * a read-only token fails with `api_token_read_only` before anything is written, and an expired
13
+ * token fails to connect with the server's `api_token_expired`.
14
+ */
15
+ export declare const VAULT_APP = "vault";
16
+ export interface VaultConnectOptions {
17
+ /** The Atlas server, e.g. `https://app.atlastodo.dev` (an `/api` suffix is fine too). */
18
+ serverUrl: string;
19
+ /** An `atlas_pat_…` token that includes Atlas Vault. */
20
+ token: string;
21
+ fetch?: FetchLike;
22
+ /** Check `GET /discovery` first (default true): a server without the vault fails clearly. */
23
+ checkServer?: boolean;
24
+ }
25
+ export interface VaultFolder {
26
+ id: string;
27
+ name: string;
28
+ created_at: number;
29
+ updated_at: number;
30
+ deleted_at: number | null;
31
+ }
32
+ export interface VaultQuery {
33
+ /** A folder id or name (case-insensitive). */
34
+ folder?: string;
35
+ /** An item type. */
36
+ type?: string;
37
+ /** A tag, case-insensitive. */
38
+ tag?: string;
39
+ /** Case-insensitive substring of the name, a URL or a tag. */
40
+ search?: string;
41
+ }
42
+ /** An item with its folder's name. */
43
+ export type VaultItemView = VaultItem & {
44
+ folder_name: string | null;
45
+ };
46
+ /** A listed item with its folder's name: no secret values. */
47
+ export type VaultItemSummaryView = VaultItemSummary & {
48
+ folder_name: string | null;
49
+ };
50
+ export declare class VaultClient {
51
+ private readonly store;
52
+ private readonly sync;
53
+ readonly userId: string;
54
+ /** Whether the token may write. */
55
+ readonly canWrite: boolean;
56
+ /** When the token expires (unix ms), if the server said. */
57
+ readonly expiresAt: number | null;
58
+ private constructor();
59
+ static connect(opts: VaultConnectOptions): Promise<VaultClient>;
60
+ private syncOrThrow;
61
+ private push;
62
+ private requireWrite;
63
+ private allItems;
64
+ private folderName;
65
+ private view;
66
+ private summaryView;
67
+ /** Folders outside the trash, by name. */
68
+ folders(): VaultFolder[];
69
+ private folderId;
70
+ /** The id of the item `ref` names: an id, or a unique name outside the trash. */
71
+ itemId(ref: string): string;
72
+ /** One item by id or name, secrets included. */
73
+ item(ref: string): VaultItemView;
74
+ /** One field's value of the item `ref` names: what a CI step reads. */
75
+ secret(ref: string, field: string): string;
76
+ /** Items outside the trash, filtered by `query`, without their secrets: by name. */
77
+ items(query?: VaultQuery): VaultItemSummaryView[];
78
+ /** `input` with its `folder` (an id or name) resolved to the folder's id. */
79
+ private withFolderId;
80
+ private write;
81
+ /** Create an item (its `folder` may be a name) and push it. */
82
+ createItem(input: VaultItemInput): Promise<VaultItemView>;
83
+ /** Change the item `ref` names and push; unchanged values write nothing. */
84
+ updateItem(ref: string, patch: VaultItemPatch): Promise<VaultItemView>;
85
+ /** Move the item `ref` names to the trash; a no-op when it already is. Returns its id. */
86
+ deleteItem(ref: string): Promise<string>;
87
+ /** Create a folder and push it. */
88
+ createFolder(name: string): Promise<VaultFolder>;
89
+ /** Stop background timers. Every write has already been pushed. */
90
+ close(): void;
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlastodo/sdk",
3
- "version": "0.1.11-rc.7",
3
+ "version": "0.1.11-rc.9",
4
4
  "description": "Headless Atlas client for integrations: signs in with a personal API token and syncs an account's tasks into memory",
5
5
  "license": "AGPL-3.0-only",
6
6
  "homepage": "https://github.com/atlastodo/atlas/tree/main/packages/sdk",
@@ -47,8 +47,8 @@
47
47
  "test": "vitest run"
48
48
  },
49
49
  "dependencies": {
50
- "@atlastodo/client-core": "0.1.11-rc.7",
51
- "@atlastodo/shared": "0.1.11-rc.7"
50
+ "@atlastodo/client-core": "0.1.11-rc.9",
51
+ "@atlastodo/shared": "0.1.11-rc.9"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@types/node": "^26.6.5",