@dxos/keys 0.10.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/keys",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Key utils and definitions.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -27,9 +27,9 @@
27
27
  ],
28
28
  "dependencies": {
29
29
  "ulidx": "^2.3.0",
30
- "@dxos/debug": "0.10.0",
31
- "@dxos/node-std": "0.10.0",
32
- "@dxos/invariant": "0.10.0"
30
+ "@dxos/debug": "0.11.0",
31
+ "@dxos/invariant": "0.11.0",
32
+ "@dxos/node-std": "0.11.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "base32-decode": "^1.0.0",
package/src/DXN.test.ts CHANGED
@@ -45,6 +45,9 @@ describe('DXN.Name', () => {
45
45
  // No dots — single segment.
46
46
  // @ts-expect-error
47
47
  DXN.make('unknown');
48
+ // One dot — two segments, below the three-segment minimum.
49
+ // @ts-expect-error
50
+ DXN.make('a.b');
48
51
  // Hyphen in the FINAL segment.
49
52
  // @ts-expect-error
50
53
  DXN.make('com.example.type.registry-entry');
package/src/DXN.ts CHANGED
@@ -31,30 +31,52 @@ const DXN_SPEC_REGEXP =
31
31
  export type DXN = URI.URI & { readonly __DXN: unique symbol };
32
32
 
33
33
  /**
34
- * Compile-time validation for NSID strings (the `dxn:` prefix is absent here).
35
- *
36
- * Checks two rules expressible with template literal types:
37
- * - Must contain at least one dot (multi-segment).
38
- * - Final segment (after the last dot) must not contain a hyphen.
34
+ * Recursive segment-chain check used by {@link Name}: hyphens are permitted in
35
+ * every segment except the truly final one.
39
36
  *
40
37
  * TypeScript template literal inference is non-greedy: `${string}.${infer Rest}`
41
38
  * always splits at the first dot. The type recurses until `Rest` has no more dots,
42
39
  * at which point it is the true final segment and is checked for hyphens.
40
+ */
41
+ type ValidSegmentChain<T extends string> = T extends `${string}.${infer Rest}`
42
+ ? Rest extends `${string}.${string}`
43
+ ? [ValidSegmentChain<Rest>] extends [never]
44
+ ? never
45
+ : T
46
+ : Rest extends `${string}-${string}`
47
+ ? never
48
+ : T
49
+ : never;
50
+
51
+ /**
52
+ * Compile-time validation for NSID strings (the `dxn:` prefix is absent here).
53
+ *
54
+ * Checks two rules expressible with template literal types:
55
+ * - Three-segment minimum (at least two dots) for names that are fully known at
56
+ * compile time — matches the runtime grammar in {@link DXN_SPEC_REGEXP} and
57
+ * `parse`.
58
+ * - Final segment (after the last dot) must not contain a hyphen.
43
59
  *
44
- * Broad `string` passes through unchanged so that template-literal call sites
45
- * whose prefix segment is `string` are not rejected those are validated at
60
+ * The three-segment minimum only applies once `Head` (the portion before the
61
+ * first dot) resolves to a concrete literal, so a fully literal two-segment
62
+ * name like `a.b` is rejected. Template-literal call sites whose prefix is a
63
+ * runtime `string` (e.g. `` `${meta.key}.event` ``) can't be proven to have
64
+ * enough segments at compile time — `Head` there infers as `string` itself —
65
+ * so only the known final segment is checked; the rest is validated at
46
66
  * runtime by the regex inside `parse`.
47
67
  */
48
68
  export type Name<T extends string> = [string] extends [T]
49
69
  ? string
50
- : T extends `${string}.${infer Rest}`
70
+ : T extends `${infer Head}.${infer Rest}`
51
71
  ? Rest extends `${string}.${string}`
52
- ? [Name<Rest>] extends [never]
53
- ? never
54
- : T
55
- : Rest extends `${string}-${string}`
72
+ ? [ValidSegmentChain<Rest>] extends [never]
56
73
  ? never
57
74
  : T
75
+ : [string] extends [Head]
76
+ ? Rest extends `${string}-${string}`
77
+ ? never
78
+ : T
79
+ : never
58
80
  : never;
59
81
 
60
82
  /**
package/src/EID.test.ts CHANGED
@@ -18,7 +18,7 @@ describe('EID.make', () => {
18
18
  });
19
19
 
20
20
  test('produces local echo URI from objectId only', ({ expect }) => {
21
- expect(EID.make({ entityId: OBJECT })).toBe(`echo:/${OBJECT}`);
21
+ expect(EID.make({ entityId: OBJECT })).toBe(`echo:///${OBJECT}`);
22
22
  });
23
23
 
24
24
  test('produces space-only echo URI from spaceId only', ({ expect }) => {
@@ -52,6 +52,11 @@ describe('EID.parse', () => {
52
52
  test('passes through canonical format unchanged', ({ expect }) => {
53
53
  const id = `echo://${SPACE}/${OBJECT}`;
54
54
  expect(EID.parse(id)).toBe(id);
55
+ expect(EID.parse(`echo:///${OBJECT}`)).toBe(`echo:///${OBJECT}`);
56
+ });
57
+
58
+ test('normalizes the legacy single-slash local form to triple-slash', ({ expect }) => {
59
+ expect(EID.parse(`echo:/${OBJECT}`)).toBe(`echo:///${OBJECT}`);
55
60
  });
56
61
 
57
62
  test('throws on invalid input', ({ expect }) => {
@@ -67,7 +72,7 @@ describe('EID.parse', () => {
67
72
  describe('EID.tryParse', () => {
68
73
  test('returns undefined on failure instead of throwing', ({ expect }) => {
69
74
  expect(EID.tryParse('not-a-uri')).toBeUndefined();
70
- expect(EID.tryParse(`echo:/${OBJECT}`)).toBe(`echo:/${OBJECT}`);
75
+ expect(EID.tryParse(`echo:/${OBJECT}`)).toBe(`echo:///${OBJECT}`);
71
76
  });
72
77
  });
73
78
 
@@ -124,6 +129,10 @@ describe('EID.equals', () => {
124
129
  const b = EID.make({ spaceId: SPACE, entityId: OBJECT2 });
125
130
  expect(EID.equals(a, b)).toBe(false);
126
131
  });
132
+
133
+ test('treats the legacy single-slash and canonical triple-slash local forms as equal', ({ expect }) => {
134
+ expect(EID.equals(`echo:/${OBJECT}` as EID.EID, `echo:///${OBJECT}` as EID.EID)).toBe(true);
135
+ });
127
136
  });
128
137
 
129
138
  describe('EID.toLocal', () => {
package/src/EID.ts CHANGED
@@ -10,25 +10,38 @@ import type { EntityId } from './entity-id';
10
10
  import type { SpaceId } from './space-id';
11
11
  import type * as URI from './URI';
12
12
 
13
- // Canonical-form regex covering all three EID shapes.
13
+ // Canonical-form regex covering all accepted EID shapes.
14
14
  // echo://<spaceId>/<objectId>
15
15
  // echo://<spaceId>
16
- // echo:/<objectId> (local)
17
- // echo:///<objectId> (local, alt form)
16
+ // echo:///<objectId> (local, canonical)
17
+ // echo:/<objectId> (local, legacy — accepted on read, normalized to the canonical triple-slash form)
18
18
  const ECHO_URI_REGEXP = /^echo:(?:\/\/[^/]+(?:\/[^/]+)?|(?:\/\/\/|\/)[^/]+)$/;
19
19
 
20
20
  // Sub-patterns used for extraction.
21
21
  const QUALIFIED_RE = /^echo:\/\/([^/]+)\/([^/]+)$/;
22
22
  const SPACE_ONLY_RE = /^echo:\/\/([^/]+)$/;
23
23
  const LOCAL_RE = /^echo:(?:\/\/\/|\/)([^/]+)$/;
24
+ // Legacy single-slash local form (`echo:/<objectId>`), distinguished from the canonical
25
+ // triple-slash form so `parse` can normalize it. The negative lookahead rejects `echo://…`.
26
+ const LOCAL_LEGACY_RE = /^echo:\/(?!\/)([^/]+)$/;
24
27
 
25
28
  /**
26
29
  * Addresses an ECHO object or space. Uses the `echo:` URI scheme.
27
30
  *
31
+ * Canonical forms:
32
+ * - `echo://<spaceId>/<objectId>` — fully-qualified object.
33
+ * - `echo://<spaceId>` — space.
34
+ * - `echo:///<objectId>` — local (space-less) object.
35
+ *
36
+ * @deprecated form: the single-slash local form `echo:/<objectId>` is retired in favour of the
37
+ * triple-slash `echo:///<objectId>` form. It is still accepted on read (and normalized by `parse`)
38
+ * so existing persisted data keeps resolving, but it is no longer produced — do not emit it in new
39
+ * code. Construct local EIDs with `make({ entityId })`.
40
+ *
28
41
  * @example
29
42
  * ```
30
43
  * echo://BA25QRC2FEWCSAMRP4RZL65LWJ7352CKE/01J00J9B45YHYSGZQTQMSKMGJ6
31
- * echo:/01J00J9B45YHYSGZQTQMSKMGJ6
44
+ * echo:///01J00J9B45YHYSGZQTQMSKMGJ6
32
45
  * echo://BA25QRC2FEWCSAMRP4RZL65LWJ7352CKE
33
46
  * ```
34
47
  */
@@ -40,13 +53,18 @@ export type EID = URI.URI & { readonly __EID: unique symbol };
40
53
  export const isEID = (value: unknown): value is EID => typeof value === 'string' && value.startsWith('echo:');
41
54
 
42
55
  /**
43
- * Parses a string to EID. Throws if the string is not a valid canonical `echo:` EID.
56
+ * Parses a string to EID. Throws if the string is not a valid `echo:` EID.
57
+ *
58
+ * The legacy single-slash local form (`echo:/<objectId>`) is accepted and normalized to the
59
+ * canonical triple-slash form (`echo:///<objectId>`) so that legacy and freshly-produced EIDs
60
+ * compare equal.
44
61
  */
45
62
  export const parse = (uri: string): EID => {
46
63
  if (!ECHO_URI_REGEXP.test(uri)) {
47
64
  throw new Error(`Invalid EID: ${uri}`);
48
65
  }
49
- return uri as EID;
66
+ const legacy = LOCAL_LEGACY_RE.exec(uri);
67
+ return (legacy ? `echo:///${legacy[1]}` : uri) as EID;
50
68
  };
51
69
 
52
70
  /**
@@ -64,7 +82,7 @@ export const tryParse = (uri: string): EID | undefined => {
64
82
  * Constructs an EID. Validates the result via `parse`.
65
83
  *
66
84
  * - `{ spaceId, entityId }` → `echo://<spaceId>/<entityId>` (fully qualified)
67
- * - `{ entityId }` → `echo:/<entityId>` (local — current space)
85
+ * - `{ entityId }` → `echo:///<entityId>` (local — current space)
68
86
  * - `{ spaceId }` → `echo://<spaceId>` (space-only)
69
87
  *
70
88
  * Throws if neither id is provided, or if the result is not a valid EID.
@@ -74,7 +92,7 @@ export const make = ({ spaceId, entityId }: { spaceId?: SpaceId; entityId?: Enti
74
92
  if (spaceId != null && entityId != null) {
75
93
  raw = `echo://${spaceId}/${entityId}`;
76
94
  } else if (entityId != null) {
77
- raw = `echo:/${entityId}`;
95
+ raw = `echo:///${entityId}`;
78
96
  } else if (spaceId != null) {
79
97
  raw = `echo://${spaceId}`;
80
98
  } else {
@@ -145,7 +163,7 @@ const Schema_: Schema.Schema<EID, EID> = Schema.String.pipe(
145
163
  }),
146
164
  Schema.annotations({
147
165
  title: 'EID',
148
- description: 'ECHO object/space URI: echo://<spaceId>[/<objectId>] or echo:/<objectId>',
166
+ description: 'ECHO object/space URI: echo://<spaceId>[/<objectId>] or echo:///<objectId>',
149
167
  }),
150
168
  ) as unknown as Schema.Schema<EID, EID>;
151
169
  export { Schema_ as Schema };
@@ -0,0 +1,47 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // Runs in the Cloudflare Workers runtime (`workerd`) via `@cloudflare/vitest-pool-workers`,
6
+ // opted in with `workerd: true` in this package's vitest config. Guards that the core key
7
+ // primitives keep working against the runtime our production Cloudflare functions execute on
8
+ // (Buffer + web-crypto `getRandomValues` under `nodejs_compat`), not just under Node.
9
+
10
+ import { describe, test } from 'vitest';
11
+
12
+ import { EntityId } from './entity-id';
13
+ import { PublicKey } from './public-key';
14
+
15
+ describe('keys in workerd', () => {
16
+ test('runs inside the Cloudflare Workers runtime', ({ expect }) => {
17
+ // workerd sets a fixed navigator.userAgent; asserts the pool actually swapped the runtime.
18
+ expect(navigator.userAgent).toBe('Cloudflare-Workers');
19
+ });
20
+
21
+ test('PublicKey.random produces distinct hex-encoded keys', ({ expect }) => {
22
+ const first = PublicKey.random();
23
+ const second = PublicKey.random();
24
+ expect(first.toHex()).toHaveLength(64);
25
+ expect(PublicKey.equals(first, first)).toBe(true);
26
+ expect(PublicKey.equals(first, second)).toBe(false);
27
+ });
28
+
29
+ test('PublicKey round-trips through hex', ({ expect }) => {
30
+ const key = PublicKey.random();
31
+ expect(PublicKey.from(key.toHex()).equals(key)).toBe(true);
32
+ });
33
+
34
+ test('EntityId.random yields valid ids', ({ expect }) => {
35
+ const id = EntityId.random();
36
+ expect(EntityId.isValid(id)).toBe(true);
37
+ });
38
+
39
+ test('EntityId.deterministic is stable and avoids the platform RNG', ({ expect }) => {
40
+ // Workerd forbids `crypto.getRandomValues()` in global scope, so deterministic() must be
41
+ // reachable at module-eval time — its stability doubles as that guard here.
42
+ const first = EntityId.deterministic('org.dxos.type.person', '0.1.0');
43
+ const second = EntityId.deterministic('org.dxos.type.person', '0.1.0');
44
+ expect(first).toBe(second);
45
+ expect(EntityId.isValid(first)).toBe(true);
46
+ });
47
+ });