From 06badeab82dd74e060d84c92d1073a6430be841f Mon Sep 17 00:00:00 2001 From: Michiel de Jong Date: Wed, 9 Sep 2026 14:22:02 +0200 Subject: [PATCH] Support DID subjects and links in native Atomic Data --- README.md | 3 +- __tests__/atomic/lens.test.ts | 33 ++++++++++++++++++++ __tests__/atomic/resources.test.ts | 50 +++++++++++++++++++++++++++++- docs/atomic-data.md | 6 ++-- src/atomic/IdentityMap.ts | 2 ++ src/atomic/Resource.ts | 27 ++++++++++++---- 6 files changed, 111 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b9ff2c9..a2bcc60 100644 --- a/README.md +++ b/README.md @@ -108,4 +108,5 @@ git push git push --tags pnpm build:release pnpm publish -``` \ No newline at end of file +``` +The native resource API accepts HTTP(S) and DID identities, including AtomicServer `did:ad:` resources, properties and links. Identity allocation still uses an HTTP(S) base; bind existing DID resources explicitly. See [Atomic Data API](docs/atomic-data.md). diff --git a/__tests__/atomic/lens.test.ts b/__tests__/atomic/lens.test.ts index 24bfa7b..ea25394 100644 --- a/__tests__/atomic/lens.test.ts +++ b/__tests__/atomic/lens.test.ts @@ -185,3 +185,36 @@ describe('Atomic Extract Entity lens', () => { expect(connector.delete).toHaveBeenCalledTimes(2); }); }); + +it('ingests and republishes an explicitly bound DID after restart without duplication', async () => { + const first = setup(); + const subject = 'did:ad:existing/resource+signature=='; + const scope = { scope: 'did:ad:agent:account=', entity: 'event' }; + const makeLens = (state: ReturnType): AtomicLens => + new AtomicLens({ + store: state.store, + identities: state.ids, + connector: state.connector, + ...scope, + read: (record: FlatOrder): { set: Record } => ({ + set: { [v.quantity]: record.quantity }, + }), + write: (resource, previous): FlatOrder => ({ + ...previous!, + quantity: resource[v.quantity] as number, + }), + }); + first.ids.bind(scope, input.id, subject); + await makeLens(first).ingest(input); + const restored = setup(first.store.toJSONAD()); + restored.records.set(input.id, input); + const lens = makeLens(restored); + expect(await lens.ingest(input)).toBe(subject); + restored.store.patch(subject, { set: { [v.quantity]: 4 } }); + await lens.publish(subject); + expect(restored.connector.create).not.toHaveBeenCalled(); + expect(restored.records.get(input.id)?.quantity).toBe(4); + expect(restored.store.all().filter((r) => r['@id'] === subject)).toHaveLength( + 1, + ); +}); diff --git a/__tests__/atomic/resources.test.ts b/__tests__/atomic/resources.test.ts index 10057c9..1d12e0c 100644 --- a/__tests__/atomic/resources.test.ts +++ b/__tests__/atomic/resources.test.ts @@ -44,7 +44,9 @@ describe('AtomicStore', () => { original[link][name] = 'mutated'; const copy = store.get(a)!; (copy[link] as Record)[name] = 'also mutated'; - const [parsed] = new JSONADParser().parseArray(JSON.parse(store.toJSONAD())); + const [parsed] = new JSONADParser().parseArray( + JSON.parse(store.toJSONAD()), + ); expect(parsed).toHaveLength(1); const restored = setup(); restored.loadJSONAD(store.toJSONAD()); @@ -143,3 +145,49 @@ describe('AtomicIdentityMap', () => { expect(() => ids.bind(scope, '', b)).toThrow(); }); }); + +describe('DID resources', () => { + const subject = 'did:ad:abc/def+ghi=='; + const property = 'did:ad:property'; + const classId = 'did:ad:class'; + const scope = { scope: 'did:ad:agent:abc+def=', entity: 'event' }; + const create = (): AtomicStore => + new AtomicStore(new AtomicSchema().property(property, Datatype.ATOMIC_URL)); + + it('preserves DID subjects, property keys, classes, links and identity mappings across restart', () => { + const store = create(); + const ids = new AtomicIdentityMap(store, 'https://example.com/bridge'); + store.put({ '@id': subject, [property]: scope.scope, [IS_A]: [classId] }); + ids.bind(scope, 'event-1', subject); + const restored = create(); + const restoredIds = new AtomicIdentityMap( + restored, + 'https://example.com/bridge', + ); + restored.loadJSONAD(store.toJSONAD()); + expect(restored.all(classId)[0]['@id']).toBe(subject); + expect(restoredIds.subjectFor(scope, 'event-1')).toBe(subject); + expect(restoredIds.externalId(scope, subject)).toBe('event-1'); + restored.patch(subject, { set: { [property]: 'did:key:z6MkExample' } }); + expect(restored.get(subject)?.[property]).toBe('did:key:z6MkExample'); + restored.patch(subject, { unset: [property] }); + expect(restored.get(subject)?.[property]).toBeUndefined(); + }); + + it.each([ + 'did:', + 'did:ad:', + 'did::abc', + 'did:AD:abc', + 'did:ad:has space', + 'did:ad:%ZZ', + 'javascript:alert(1)', + 'file:///tmp/resource', + ])('rejects invalid subject %s', (id) => { + expect(() => create().put({ '@id': id })).toThrow(); + }); + + it('requires an HTTP(S) allocator base even when resources use DIDs', () => { + expect(() => new AtomicIdentityMap(create(), subject)).toThrow('HTTP(S)'); + }); +}); diff --git a/docs/atomic-data.md b/docs/atomic-data.md index 433bdac..3e640a6 100644 --- a/docs/atomic-data.md +++ b/docs/atomic-data.md @@ -22,9 +22,9 @@ store.patch(subject, { unset: [title] }); `apply([{ subject, patch }, ...])` validates and applies related resource changes as a local transaction. A validation failure leaves all resources untouched. `transaction(callback)` groups synchronous store and identity operations with rollback; the callback must not be async or launch asynchronous work. -`AtomicSchema` is an offline property catalog. Define every property before importing data; unknown properties are rejected. It uses `@tomic/lib` datatype validation, with additional primitive, finite-number and link checks. This release supports the SDK's string, markdown, slug, boolean, integer, float, date, timestamp, atomicURL and resourceArray types. Links may be HTTP(S) URLs or nested resources with property URL keys and no `@id`. Named resources are permitted at the JSON-AD document root or in its root array. +`AtomicSchema` is an offline property catalog. Define every property before importing data; unknown properties are rejected. It uses `@tomic/lib` datatype validation, with additional primitive, finite-number and link checks. This release supports the SDK's string, markdown, slug, boolean, integer, float, date, timestamp, atomicURL and resourceArray types. Links may be HTTP(S) URLs or DID identifiers or nested resources with property URL keys and no `@id`. Named resources are permitted at the JSON-AD document root or in its root array. -This is a deliberately bounded JSON-AD profile: HTTP(S) subjects, explicitly registered datatypes, no arbitrary JSON or Yjs datatypes, no automatic remote schema resolution, and no required-property/class-constraint validation. Application lenses must check required fields. Publish your property's Atomic Schema definitions at their URLs for interoperability; declaring a local catalog does not publish them. The example.com vocabulary in examples is illustrative. +This is a deliberately bounded JSON-AD profile: HTTP(S) or DID subjects, explicitly registered datatypes, no arbitrary JSON or Yjs datatypes, no automatic remote schema resolution, and no required-property/class-constraint validation. Application lenses must check required fields. Publish your property's Atomic Schema definitions at their URLs for interoperability; declaring a local catalog does not publish them. The example.com vocabulary in examples is illustrative. ## External identities @@ -105,3 +105,5 @@ There are no revision clocks, stale-event rejection, cross-system conflict resol The behavioral tests cover distinct identical records, scoped identities, round-trip snapshots, malformed input rollback, linked entity extraction, reverse updates, field preservation/removal, replay, queued creates, lost responses and deletion failures. Specification references: [JSON-AD](https://docs.atomicdata.dev/core/json-ad.html), [Atomic Schema](https://docs.atomicdata.dev/schema/intro.html), [Atomic Commits](https://docs.atomicdata.dev/commits/intro.html), [TypeScript SDK](https://atomicdata-dev.github.io/atomic-data-browser/docs/modules/_tomic_lib.html). + +DID identifiers (including AtomicServer `did:ad:` base64 identities) are accepted as resource subjects, property keys, classes, links and connector scopes. Identifiers are preserved exactly; validation does not resolve DIDs or verify signatures. Identity allocator bases still require HTTP(S) URLs because the allocator appends child paths. Bind existing DID resources explicitly before ingesting their external records. diff --git a/src/atomic/IdentityMap.ts b/src/atomic/IdentityMap.ts index 12f88ca..f526a9a 100644 --- a/src/atomic/IdentityMap.ts +++ b/src/atomic/IdentityMap.ts @@ -27,6 +27,8 @@ export class AtomicIdentityMap { ) { assertSubject(baseURL); const url = new URL(baseURL); + if (!['http:', 'https:'].includes(url.protocol)) + throw new Error('Identity base must be an HTTP(S) URL'); if (url.search || url.hash) throw new Error('Identity base URL cannot contain a query or fragment'); this.base = baseURL.replace(/\/$/, ''); diff --git a/src/atomic/Resource.ts b/src/atomic/Resource.ts index 85f804c..21d5c9f 100644 --- a/src/atomic/Resource.ts +++ b/src/atomic/Resource.ts @@ -17,14 +17,29 @@ export interface AtomicPatch { } export const IS_A = 'https://atomicdata.dev/properties/isA'; -/** Require absolute HTTP(S) URLs for this implementation's resource profile. */ +/** Accept HTTP(S) and DID identifiers without normalizing identity strings. + * Atomic's did:ad profile includes base64 (+, /, =) identifiers. This is + * syntactic validation only, not DID resolution or signature verification. + */ export function assertSubject(value: string): void { - if (typeof value !== 'string' || /\s/.test(value)) - throw new Error('Expected an absolute URL without whitespace'); + if ( + typeof value !== 'string' || + /\s/.test(value) || + [...value].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127) + ) + throw new Error( + 'Expected an absolute identifier without whitespace or controls', + ); const url = new URL(value); - if (!['http:', 'https:'].includes(url.protocol)) { - throw new Error(`Expected an HTTP(S) URL: ${value}`); - } + if (['http:', 'https:'].includes(url.protocol)) return; + if ( + /^did:[a-z0-9]+:[A-Za-z0-9._:%+/=-]+(?:[?#][A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]*)?$/.test( + value, + ) && + !/%(?![0-9a-fA-F]{2})/.test(value) + ) + return; + throw new Error(`Expected an HTTP(S) URL or DID: ${value}`); } /** Offline property catalog. Callers supply definitions rather than fetching schemas. */