@hypercerts-org/sdk-core 0.10.0-beta.4 → 0.10.0-beta.5-hotfix.1

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.cjs CHANGED
@@ -3,9 +3,41 @@
3
3
  var oauthClientNode = require('@atproto/oauth-client-node');
4
4
  var zod = require('zod');
5
5
  var api = require('@atproto/api');
6
- var eventemitter3 = require('eventemitter3');
6
+ var lexicon$1 = require('@atproto/lexicon');
7
7
  var lexicon = require('@hypercerts-org/lexicon');
8
- require('@atproto/lexicon');
8
+ var eventemitter3 = require('eventemitter3');
9
+
10
+ /**
11
+ * Type guard to check if a URL is a loopback address.
12
+ *
13
+ * Loopback addresses are used for local development and testing.
14
+ * They include localhost, 127.0.0.1, and [::1] (IPv6 loopback).
15
+ *
16
+ * @param url - The URL to check
17
+ * @returns True if the URL is a loopback address
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * isLoopbackUrl('http://localhost:3000'); // true
22
+ * isLoopbackUrl('http://127.0.0.1:8080'); // true
23
+ * isLoopbackUrl('http://[::1]:3000'); // true
24
+ * isLoopbackUrl('http://example.com'); // false
25
+ * isLoopbackUrl('https://localhost:3000'); // false (must be http)
26
+ * ```
27
+ */
28
+ function isLoopbackUrl(url) {
29
+ try {
30
+ const parsed = new URL(url);
31
+ if (parsed.protocol !== "http:") {
32
+ return false;
33
+ }
34
+ const hostname = parsed.hostname.toLowerCase();
35
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
9
41
 
10
42
  /**
11
43
  * Base error class for all SDK errors.
@@ -599,24 +631,47 @@ const IdentityAttrSchema = zod.z.enum(["handle", "*"]);
599
631
  /**
600
632
  * Zod schema for MIME type patterns.
601
633
  *
602
- * Validates MIME type strings like "image/*" or "video/mp4".
634
+ * Validates MIME type strings used in blob permissions.
635
+ * Supports standard MIME types and wildcard patterns per ATProto spec.
636
+ *
637
+ * **References:**
638
+ * - ATProto Permission Spec: https://atproto.com/specs/permission (blob resource)
639
+ * - RFC 2045 (MIME): https://www.rfc-editor.org/rfc/rfc2045 (token definition)
640
+ * - IANA Media Types: https://www.iana.org/assignments/media-types/
641
+ *
642
+ * **Implementation:**
643
+ * This is a "good enough" validation that allows common real-world MIME types:
644
+ * - Type: letters, digits (e.g., "3gpp")
645
+ * - Subtype: letters, digits, hyphens, plus signs, dots, underscores, wildcards
646
+ * - Examples: "image/png", "application/vnd.api+json", "video/*", "clue_info+xml"
647
+ *
648
+ * Note: We use a simplified regex rather than full RFC 2045 token validation
649
+ * for practicality. Zod v4 has native MIME support (z.file().mime()) but would
650
+ * require a larger migration effort.
603
651
  *
604
652
  * @example
605
653
  * ```typescript
606
- * MimeTypeSchema.parse('image/*'); // Valid
607
- * MimeTypeSchema.parse('video/mp4'); // Valid
654
+ * MimeTypeSchema.parse('image/*'); // Valid - wildcard
655
+ * MimeTypeSchema.parse('video/mp4'); // Valid - standard
656
+ * MimeTypeSchema.parse('application/vnd.api+json'); // Valid - with dots/plus
608
657
  * MimeTypeSchema.parse('invalid'); // Throws ZodError
609
658
  * ```
610
659
  */
611
660
  const MimeTypeSchema = zod.z
612
661
  .string()
613
- .regex(/^[a-z]+\/[a-z0-9*+-]+$/i, 'Invalid MIME type pattern. Expected format: type/subtype (e.g., "image/*" or "video/mp4")');
662
+ .regex(/^[a-z0-9]+\/[a-z0-9*+._-]+$/i, 'Invalid MIME type pattern. Expected format: type/subtype (e.g., "image/*", "video/mp4", "application/vnd.api+json")');
614
663
  /**
615
664
  * Zod schema for NSID (Namespaced Identifier).
616
665
  *
617
666
  * NSIDs are reverse-DNS style identifiers used throughout ATProto
618
667
  * (e.g., "app.bsky.feed.post" or "com.example.myrecord").
619
668
  *
669
+ * Official ATProto NSID spec requires:
670
+ * - Each segment must be 1-63 characters
671
+ * - Authority segments (all but last) can contain hyphens, but not at boundaries
672
+ * - Name segment (last) must start with a letter and contain only alphanumerics
673
+ * - Hyphens only allowed in authority segments, not in the name segment
674
+ *
620
675
  * @see https://atproto.com/specs/nsid
621
676
  *
622
677
  * @example
@@ -628,7 +683,7 @@ const MimeTypeSchema = zod.z
628
683
  */
629
684
  const NsidSchema = zod.z
630
685
  .string()
631
- .regex(/^[a-zA-Z][a-zA-Z0-9-]*(\.[a-zA-Z][a-zA-Z0-9-]*)+$/, 'Invalid NSID format. Expected reverse-DNS format (e.g., "app.bsky.feed.post")');
686
+ .regex(/^[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(\.[a-zA-Z]([a-zA-Z0-9]{0,62})?)$/, 'Invalid NSID format. Expected reverse-DNS format (e.g., "app.bsky.feed.post")');
632
687
  /**
633
688
  * Zod schema for account permission.
634
689
  *
@@ -1379,27 +1434,119 @@ const ScopePresets = {
1379
1434
  function parseScope(scope) {
1380
1435
  return scope.trim().split(/\s+/).filter(Boolean);
1381
1436
  }
1437
+ /**
1438
+ * Helper function to match MIME type patterns with wildcard support.
1439
+ *
1440
+ * Implements MIME type matching for blob permissions per ATProto spec.
1441
+ *
1442
+ * **Reference:**
1443
+ * - ATProto Permission Spec: https://atproto.com/specs/permission
1444
+ * Supports "MIME types or partial MIME type glob patterns"
1445
+ *
1446
+ * **Supported patterns:**
1447
+ * - Exact matches: "image/png" matches "image/png"
1448
+ * - Type wildcards: "image/*" matches "image/png", "image/jpeg", etc.
1449
+ * - Full wildcards: `*` `/` `*` matches any MIME type
1450
+ *
1451
+ * @param pattern - The MIME type pattern (may contain wildcards)
1452
+ * @param mimeType - The actual MIME type to check
1453
+ * @returns True if the MIME type matches the pattern
1454
+ *
1455
+ * @example
1456
+ * ```typescript
1457
+ * matchMimePattern("image/*", "image/png"); // true
1458
+ * matchMimePattern("*" + "/" + "*", "video/mp4"); // true - matches any MIME
1459
+ * matchMimePattern("image/*", "video/mp4"); // false
1460
+ * ```
1461
+ */
1462
+ function matchMimePattern(pattern, mimeType) {
1463
+ if (pattern === "*/*")
1464
+ return true;
1465
+ if (pattern === mimeType)
1466
+ return true;
1467
+ const [patternType, patternSubtype] = pattern.split("/");
1468
+ const [mimeTypeType] = mimeType.split("/");
1469
+ if (patternSubtype === "*" && patternType === mimeTypeType) {
1470
+ return true;
1471
+ }
1472
+ return false;
1473
+ }
1382
1474
  /**
1383
1475
  * Check if a scope string contains a specific permission.
1384
1476
  *
1385
- * This function performs exact string matching. For more advanced
1386
- * permission checking (e.g., wildcard matching), you'll need to
1387
- * implement custom logic.
1477
+ * Implements permission matching per ATProto OAuth spec with support for
1478
+ * exact matching and limited wildcard patterns.
1479
+ *
1480
+ * **References:**
1481
+ * - ATProto Permission Spec: https://atproto.com/specs/permission
1482
+ * - ATProto OAuth Spec: https://atproto.com/specs/oauth
1483
+ *
1484
+ * **Supported wildcards (per spec):**
1485
+ * - `repo:*` - Matches any repository collection (e.g., `repo:app.bsky.feed.post`)
1486
+ * - `rpc:*` - Matches any RPC lexicon (but aud cannot also be wildcard)
1487
+ * - `blob:image/*` - MIME type wildcards (e.g., matches `blob:image/png`, `blob:image/jpeg`)
1488
+ * - `blob:` + wildcard MIME - Matches any MIME type (using `*` + `/` + `*` pattern)
1489
+ * - `identity:*` - Full control of DID document and handle (spec allows `*` as attr value)
1490
+ *
1491
+ * **NOT supported (per spec):**
1492
+ * - `account:*` - Account attr does not support wildcards (only `email` and `repo` allowed)
1493
+ * - `include:*` - Include NSID does not support wildcards
1494
+ * - Partial wildcards like `com.example.*` are not supported
1388
1495
  *
1389
1496
  * @param scope - Space-separated scope string
1390
1497
  * @param permission - The permission to check for
1391
1498
  * @returns True if the scope contains the permission
1392
1499
  *
1393
- * @example
1500
+ * @example Exact matching
1394
1501
  * ```typescript
1395
- * const scope = "account:email?action=read repo:app.bsky.feed.post";
1396
- * hasPermission(scope, "account:email?action=read"); // true
1502
+ * const scope = "account:email repo:app.bsky.feed.post";
1503
+ * hasPermission(scope, "account:email"); // true
1397
1504
  * hasPermission(scope, "account:repo"); // false
1398
1505
  * ```
1506
+ *
1507
+ * @example Wildcard matching
1508
+ * ```typescript
1509
+ * const scope = "repo:* blob:image/* identity:*";
1510
+ * hasPermission(scope, "repo:app.bsky.feed.post"); // true
1511
+ * hasPermission(scope, "blob:image/png"); // true
1512
+ * hasPermission(scope, "blob:video/mp4"); // false
1513
+ * hasPermission(scope, "identity:handle"); // true
1514
+ * ```
1399
1515
  */
1400
1516
  function hasPermission(scope, permission) {
1401
1517
  const permissions = parseScope(scope);
1402
- return permissions.includes(permission);
1518
+ // 1. Check exact match first
1519
+ if (permissions.includes(permission)) {
1520
+ return true;
1521
+ }
1522
+ // 2. Check wildcard matches (only those supported by ATProto spec)
1523
+ for (const scopePermission of permissions) {
1524
+ // repo:* - Wildcard for repository collections
1525
+ // Spec: "Wildcard (*) is allowed in scope string syntax"
1526
+ if (scopePermission.startsWith("repo:*") && permission.startsWith("repo:")) {
1527
+ return true;
1528
+ }
1529
+ // rpc:* - Wildcard for RPC lexicons
1530
+ // Spec: "Wildcard (*) is allowed in scope string syntax for lxm parameter"
1531
+ if (scopePermission.startsWith("rpc:*") && permission.startsWith("rpc:")) {
1532
+ return true;
1533
+ }
1534
+ // blob MIME wildcards - image/*, video/*, or full wildcard
1535
+ // Spec: "MIME types or partial MIME type glob patterns"
1536
+ if (scopePermission.startsWith("blob:") && permission.startsWith("blob:")) {
1537
+ const scopeMime = scopePermission.substring(5).split("?")[0]; // Remove "blob:" prefix and query params
1538
+ const permMime = permission.substring(5).split("?")[0];
1539
+ if (matchMimePattern(scopeMime, permMime)) {
1540
+ return true;
1541
+ }
1542
+ }
1543
+ // identity:* - Full control of DID document and handle
1544
+ // Spec: "* - Full control of DID document and handle" (as attr value)
1545
+ if (scopePermission === "identity:*" && permission.startsWith("identity:")) {
1546
+ return true;
1547
+ }
1548
+ }
1549
+ return false;
1403
1550
  }
1404
1551
  /**
1405
1552
  * Check if a scope string contains all of the specified permissions.
@@ -1623,6 +1770,26 @@ class OAuthClient {
1623
1770
  async getClient() {
1624
1771
  return this.clientPromise;
1625
1772
  }
1773
+ /**
1774
+ * Detects if a URL is a loopback address (localhost or 127.0.0.1 or [::1]).
1775
+ *
1776
+ * @param urlString - The URL to check
1777
+ * @returns True if the URL is an HTTP loopback address
1778
+ * @internal
1779
+ */
1780
+ isLoopbackUrl(urlString) {
1781
+ try {
1782
+ const url = new URL(urlString);
1783
+ if (url.protocol !== "http:") {
1784
+ return false;
1785
+ }
1786
+ const hostname = url.hostname.toLowerCase();
1787
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
1788
+ }
1789
+ catch {
1790
+ return false;
1791
+ }
1792
+ }
1626
1793
  /**
1627
1794
  * Builds OAuth client metadata from configuration.
1628
1795
  *
@@ -1641,6 +1808,23 @@ class OAuthClient {
1641
1808
  */
1642
1809
  buildClientMetadata() {
1643
1810
  const clientIdUrl = new URL(this.config.oauth.clientId);
1811
+ // Detect and warn about loopback configuration
1812
+ const isDevelopment = isLoopbackUrl(this.config.oauth.clientId) || isLoopbackUrl(this.config.oauth.redirectUri);
1813
+ if (isDevelopment && !this.config.oauth.developmentMode) {
1814
+ this.logger?.warn("Using HTTP loopback URLs without explicit developmentMode flag", {
1815
+ clientId: this.config.oauth.clientId,
1816
+ redirectUri: this.config.oauth.redirectUri,
1817
+ note: "This is suitable for local development only. For production, use HTTPS URLs.",
1818
+ recommendation: "Set oauth.developmentMode: true to suppress this warning.",
1819
+ });
1820
+ }
1821
+ if (isDevelopment) {
1822
+ this.logger?.info("Running in development mode with loopback URLs", {
1823
+ clientId: this.config.oauth.clientId,
1824
+ redirectUri: this.config.oauth.redirectUri,
1825
+ note: "Authorization server must support loopback clients (optional per AT Protocol spec)",
1826
+ });
1827
+ }
1644
1828
  const metadata = {
1645
1829
  client_id: this.config.oauth.clientId,
1646
1830
  client_name: "ATProto SDK Client",
@@ -2078,6 +2262,299 @@ class ConfigurableAgent extends api.Agent {
2078
2262
  }
2079
2263
  }
2080
2264
 
2265
+ /**
2266
+ * LexiconRegistry - Manages custom lexicon registration and validation.
2267
+ *
2268
+ * This module provides a registry for AT Protocol lexicon schemas,
2269
+ * allowing developers to register custom lexicons and validate records
2270
+ * against registered schemas.
2271
+ *
2272
+ * @packageDocumentation
2273
+ */
2274
+ /**
2275
+ * Registry for managing AT Protocol lexicon schemas.
2276
+ *
2277
+ * The LexiconRegistry allows developers to:
2278
+ * - Register custom lexicon definitions
2279
+ * - Validate records against registered schemas
2280
+ * - Query registered lexicons
2281
+ * - Add lexicons to AT Protocol agents
2282
+ *
2283
+ * @example Basic usage
2284
+ * ```typescript
2285
+ * const registry = new LexiconRegistry();
2286
+ *
2287
+ * // Register a custom lexicon
2288
+ * registry.register({
2289
+ * lexicon: 1,
2290
+ * id: "org.myapp.customRecord",
2291
+ * defs: {
2292
+ * main: {
2293
+ * type: "record",
2294
+ * key: "tid",
2295
+ * record: {
2296
+ * type: "object",
2297
+ * required: ["$type", "title"],
2298
+ * properties: {
2299
+ * "$type": { type: "string", const: "org.myapp.customRecord" },
2300
+ * title: { type: "string" }
2301
+ * }
2302
+ * }
2303
+ * }
2304
+ * }
2305
+ * });
2306
+ *
2307
+ * // Validate a record
2308
+ * const result = registry.validate("org.myapp.customRecord", {
2309
+ * $type: "org.myapp.customRecord",
2310
+ * title: "My Record"
2311
+ * });
2312
+ *
2313
+ * if (!result.valid) {
2314
+ * console.error(result.error);
2315
+ * }
2316
+ * ```
2317
+ */
2318
+ class LexiconRegistry {
2319
+ /**
2320
+ * Creates a new LexiconRegistry instance.
2321
+ *
2322
+ * @param initialLexicons - Optional array of lexicons to register on initialization
2323
+ */
2324
+ constructor(initialLexicons) {
2325
+ this.lexicons = new lexicon$1.Lexicons();
2326
+ this.registeredIds = new Set();
2327
+ if (initialLexicons && initialLexicons.length > 0) {
2328
+ this.registerMany(initialLexicons);
2329
+ }
2330
+ }
2331
+ /**
2332
+ * Registers a single lexicon definition.
2333
+ *
2334
+ * @param lexicon - The lexicon document to register
2335
+ * @throws {Error} If the lexicon is invalid or already registered
2336
+ *
2337
+ * @example
2338
+ * ```typescript
2339
+ * registry.register({
2340
+ * lexicon: 1,
2341
+ * id: "org.myapp.customRecord",
2342
+ * defs: { ... }
2343
+ * });
2344
+ * ```
2345
+ */
2346
+ register(lexicon) {
2347
+ if (!lexicon.id) {
2348
+ throw new Error("Lexicon must have an id");
2349
+ }
2350
+ if (this.registeredIds.has(lexicon.id)) {
2351
+ throw new Error(`Lexicon ${lexicon.id} is already registered`);
2352
+ }
2353
+ try {
2354
+ // Check if the lexicon already exists in the internal store
2355
+ // (e.g., after unregister which only removes from registeredIds)
2356
+ const existingLexicon = this.lexicons.get(lexicon.id);
2357
+ if (!existingLexicon) {
2358
+ // Lexicon is truly new, add it to the store
2359
+ this.lexicons.add(lexicon);
2360
+ }
2361
+ // Always add to registeredIds (re-enable if previously unregistered)
2362
+ this.registeredIds.add(lexicon.id);
2363
+ }
2364
+ catch (error) {
2365
+ throw new Error(`Failed to register lexicon ${lexicon.id}: ${error instanceof Error ? error.message : "Unknown error"}`);
2366
+ }
2367
+ }
2368
+ /**
2369
+ * Registers multiple lexicon definitions at once.
2370
+ *
2371
+ * @param lexicons - Array of lexicon documents to register
2372
+ * @throws {Error} If any lexicon is invalid or already registered
2373
+ *
2374
+ * @example
2375
+ * ```typescript
2376
+ * registry.registerMany([lexicon1, lexicon2, lexicon3]);
2377
+ * ```
2378
+ */
2379
+ registerMany(lexicons) {
2380
+ for (const lexicon of lexicons) {
2381
+ this.register(lexicon);
2382
+ }
2383
+ }
2384
+ /**
2385
+ * Registers a lexicon from a JSON object.
2386
+ *
2387
+ * This is a convenience method for registering lexicons loaded from JSON files.
2388
+ *
2389
+ * @param lexiconJson - The lexicon as a plain JavaScript object
2390
+ * @throws {ValidationError} If the lexicon is not a valid object
2391
+ * @throws {Error} If the lexicon is invalid or already registered
2392
+ *
2393
+ * @example
2394
+ * ```typescript
2395
+ * import customLexicon from "./custom-lexicon.json";
2396
+ * registry.registerFromJSON(customLexicon);
2397
+ * ```
2398
+ */
2399
+ registerFromJSON(lexiconJson) {
2400
+ // Validate that input is an object and not null
2401
+ if (typeof lexiconJson !== "object" || lexiconJson === null) {
2402
+ throw new ValidationError("Lexicon JSON must be a valid object");
2403
+ }
2404
+ // Now we can safely cast to LexiconDoc and register
2405
+ this.register(lexiconJson);
2406
+ }
2407
+ /**
2408
+ * Unregisters a lexicon by its NSID.
2409
+ *
2410
+ * @param nsid - The NSID of the lexicon to unregister
2411
+ * @returns True if the lexicon was unregistered, false if it wasn't registered
2412
+ *
2413
+ * @example
2414
+ * ```typescript
2415
+ * registry.unregister("org.myapp.customRecord");
2416
+ * ```
2417
+ */
2418
+ unregister(nsid) {
2419
+ if (!this.registeredIds.has(nsid)) {
2420
+ return false;
2421
+ }
2422
+ this.registeredIds.delete(nsid);
2423
+ // Note: Lexicons class doesn't have a remove method,
2424
+ // so we can't actually remove from the internal store.
2425
+ // We track removal in our Set for isRegistered checks.
2426
+ return true;
2427
+ }
2428
+ /**
2429
+ * Checks if a lexicon is registered.
2430
+ *
2431
+ * @param nsid - The NSID to check
2432
+ * @returns True if the lexicon is registered
2433
+ *
2434
+ * @example
2435
+ * ```typescript
2436
+ * if (registry.isRegistered("org.myapp.customRecord")) {
2437
+ * // Lexicon is available
2438
+ * }
2439
+ * ```
2440
+ */
2441
+ isRegistered(nsid) {
2442
+ return this.registeredIds.has(nsid);
2443
+ }
2444
+ /**
2445
+ * Gets a lexicon definition by its NSID.
2446
+ *
2447
+ * @param nsid - The NSID of the lexicon to retrieve
2448
+ * @returns The lexicon document, or undefined if not found
2449
+ *
2450
+ * @example
2451
+ * ```typescript
2452
+ * const lexicon = registry.get("org.myapp.customRecord");
2453
+ * if (lexicon) {
2454
+ * console.log(lexicon.defs);
2455
+ * }
2456
+ * ```
2457
+ */
2458
+ get(nsid) {
2459
+ if (!this.isRegistered(nsid)) {
2460
+ return undefined;
2461
+ }
2462
+ return this.lexicons.get(nsid);
2463
+ }
2464
+ /**
2465
+ * Gets all registered lexicon NSIDs.
2466
+ *
2467
+ * @returns Array of registered NSIDs
2468
+ *
2469
+ * @example
2470
+ * ```typescript
2471
+ * const registered = registry.getAll();
2472
+ * console.log(`Registered lexicons: ${registered.join(", ")}`);
2473
+ * ```
2474
+ */
2475
+ getAll() {
2476
+ return Array.from(this.registeredIds);
2477
+ }
2478
+ /**
2479
+ * Validates a record against a registered lexicon.
2480
+ *
2481
+ * @param nsid - The collection NSID to validate against
2482
+ * @param record - The record data to validate
2483
+ * @returns Validation result with success status and optional error message
2484
+ *
2485
+ * @example
2486
+ * ```typescript
2487
+ * const result = registry.validate("org.myapp.customRecord", {
2488
+ * $type: "org.myapp.customRecord",
2489
+ * title: "My Record"
2490
+ * });
2491
+ *
2492
+ * if (!result.valid) {
2493
+ * console.error(`Validation failed: ${result.error}`);
2494
+ * }
2495
+ * ```
2496
+ */
2497
+ validate(nsid, record) {
2498
+ if (!this.isRegistered(nsid)) {
2499
+ return {
2500
+ valid: false,
2501
+ error: `Lexicon ${nsid} is not registered`,
2502
+ };
2503
+ }
2504
+ try {
2505
+ this.lexicons.assertValidRecord(nsid, record);
2506
+ return { valid: true };
2507
+ }
2508
+ catch (error) {
2509
+ return {
2510
+ valid: false,
2511
+ error: error instanceof Error ? error.message : "Validation failed",
2512
+ };
2513
+ }
2514
+ }
2515
+ /**
2516
+ * Adds all registered lexicons to an AT Protocol Agent.
2517
+ *
2518
+ * This method is currently a no-op as the AT Protocol Agent
2519
+ * doesn't provide a public API for adding lexicons at runtime.
2520
+ * Lexicons must be registered with the server.
2521
+ *
2522
+ * This method is kept for future compatibility if the API
2523
+ * adds support for client-side lexicon registration.
2524
+ *
2525
+ * @param _agent - The AT Protocol Agent (currently unused)
2526
+ *
2527
+ * @example
2528
+ * ```typescript
2529
+ * const agent = new Agent(session);
2530
+ * registry.addToAgent(agent);
2531
+ * // Reserved for future use
2532
+ * ```
2533
+ */
2534
+ addToAgent(_agent) {
2535
+ // No-op: AT Protocol Agent doesn't support client-side lexicon addition
2536
+ // Lexicons are validated client-side via this registry,
2537
+ // but server-side validation is performed by the PDS/SDS
2538
+ }
2539
+ /**
2540
+ * Gets the underlying Lexicons instance.
2541
+ *
2542
+ * This provides direct access to the AT Protocol Lexicons object
2543
+ * for advanced use cases.
2544
+ *
2545
+ * @returns The internal Lexicons instance
2546
+ *
2547
+ * @example
2548
+ * ```typescript
2549
+ * const lexicons = registry.getLexicons();
2550
+ * // Use lexicons directly for advanced operations
2551
+ * ```
2552
+ */
2553
+ getLexicons() {
2554
+ return this.lexicons;
2555
+ }
2556
+ }
2557
+
2081
2558
  /**
2082
2559
  * RecordOperationsImpl - Low-level record CRUD operations.
2083
2560
  *
@@ -2132,12 +2609,14 @@ class RecordOperationsImpl {
2132
2609
  *
2133
2610
  * @param agent - AT Protocol Agent for making API calls
2134
2611
  * @param repoDid - DID of the repository to operate on
2612
+ * @param lexiconRegistry - Optional registry for validating records against lexicon schemas
2135
2613
  *
2136
2614
  * @internal
2137
2615
  */
2138
- constructor(agent, repoDid) {
2616
+ constructor(agent, repoDid, lexiconRegistry) {
2139
2617
  this.agent = agent;
2140
2618
  this.repoDid = repoDid;
2619
+ this.lexiconRegistry = lexiconRegistry;
2141
2620
  }
2142
2621
  /**
2143
2622
  * Creates a new record in the specified collection.
@@ -2147,14 +2626,17 @@ class RecordOperationsImpl {
2147
2626
  * @param params.record - Record data conforming to the collection's lexicon schema
2148
2627
  * @param params.rkey - Optional record key. If not provided, a TID (timestamp-based ID)
2149
2628
  * is automatically generated by the server.
2629
+ * @param params.skipValidation - Optional flag to skip lexicon validation (default: false)
2150
2630
  * @returns Promise resolving to the created record's URI and CID
2151
2631
  * @throws {@link ValidationError} if the record doesn't conform to the lexicon schema
2152
2632
  * @throws {@link NetworkError} if the API request fails
2153
2633
  *
2154
2634
  * @remarks
2155
2635
  * The record is validated against the collection's lexicon before sending
2156
- * to the server. If no lexicon is registered for the collection, validation
2157
- * is skipped (allowing custom record types).
2636
+ * to the server if the collection is registered in the LexiconRegistry.
2637
+ * If no lexicon is registered for the collection, validation is skipped
2638
+ * (allowing custom record types). Use `skipValidation: true` to bypass
2639
+ * validation even for registered collections.
2158
2640
  *
2159
2641
  * **AT-URI Format**: `at://{did}/{collection}/{rkey}`
2160
2642
  *
@@ -2174,6 +2656,13 @@ class RecordOperationsImpl {
2174
2656
  */
2175
2657
  async create(params) {
2176
2658
  try {
2659
+ // Validate record against registered lexicon if available
2660
+ if (!params.skipValidation && this.lexiconRegistry?.isRegistered(params.collection)) {
2661
+ const validation = this.lexiconRegistry.validate(params.collection, params.record);
2662
+ if (!validation.valid) {
2663
+ throw new ValidationError(`Invalid record for collection ${params.collection}: ${validation.error}`);
2664
+ }
2665
+ }
2177
2666
  const result = await this.agent.com.atproto.repo.createRecord({
2178
2667
  repo: this.repoDid,
2179
2668
  collection: params.collection,
@@ -2198,6 +2687,7 @@ class RecordOperationsImpl {
2198
2687
  * @param params.collection - NSID of the collection
2199
2688
  * @param params.rkey - Record key (the last segment of the AT-URI)
2200
2689
  * @param params.record - New record data (completely replaces existing record)
2690
+ * @param params.skipValidation - Optional flag to skip lexicon validation (default: false)
2201
2691
  * @returns Promise resolving to the updated record's URI and new CID
2202
2692
  * @throws {@link ValidationError} if the record doesn't conform to the lexicon schema
2203
2693
  * @throws {@link NetworkError} if the API request fails
@@ -2207,6 +2697,10 @@ class RecordOperationsImpl {
2207
2697
  * record is replaced with the new data. To preserve existing fields,
2208
2698
  * first fetch the record with {@link get}, modify it, then update.
2209
2699
  *
2700
+ * The record is validated against the collection's lexicon before sending
2701
+ * to the server if the collection is registered in the LexiconRegistry.
2702
+ * Use `skipValidation: true` to bypass validation.
2703
+ *
2210
2704
  * @example
2211
2705
  * ```typescript
2212
2706
  * // Get existing record
@@ -2228,6 +2722,13 @@ class RecordOperationsImpl {
2228
2722
  */
2229
2723
  async update(params) {
2230
2724
  try {
2725
+ // Validate record against registered lexicon if available
2726
+ if (!params.skipValidation && this.lexiconRegistry?.isRegistered(params.collection)) {
2727
+ const validation = this.lexiconRegistry.validate(params.collection, params.record);
2728
+ if (!validation.valid) {
2729
+ throw new ValidationError(`Invalid record for collection ${params.collection}: ${validation.error}`);
2730
+ }
2731
+ }
2231
2732
  const result = await this.agent.com.atproto.repo.putRecord({
2232
2733
  repo: this.repoDid,
2233
2734
  collection: params.collection,
@@ -2884,20 +3385,21 @@ class ProfileOperationsImpl {
2884
3385
  const HYPERCERT_LEXICONS = [
2885
3386
  lexicon.CERTIFIED_DEFS_LEXICON_JSON,
2886
3387
  lexicon.LOCATION_LEXICON_JSON,
2887
- lexicon.STRONGREF_LEXICON_JSON,
3388
+ lexicon.STRONG_REF_LEXICON_JSON,
2888
3389
  lexicon.HYPERCERTS_DEFS_LEXICON_JSON,
2889
3390
  lexicon.ACTIVITY_LEXICON_JSON,
2890
3391
  lexicon.COLLECTION_LEXICON_JSON,
2891
- lexicon.CONTRIBUTION_LEXICON_JSON,
3392
+ lexicon.CONTRIBUTION_DETAILS_LEXICON_JSON,
3393
+ lexicon.CONTRIBUTOR_INFORMATION_LEXICON_JSON,
2892
3394
  lexicon.EVALUATION_LEXICON_JSON,
2893
3395
  lexicon.EVIDENCE_LEXICON_JSON,
2894
3396
  lexicon.MEASUREMENT_LEXICON_JSON,
2895
3397
  lexicon.RIGHTS_LEXICON_JSON,
2896
- lexicon.PROJECT_LEXICON_JSON,
2897
3398
  lexicon.BADGE_AWARD_LEXICON_JSON,
2898
3399
  lexicon.BADGE_DEFINITION_LEXICON_JSON,
2899
3400
  lexicon.BADGE_RESPONSE_LEXICON_JSON,
2900
3401
  lexicon.FUNDING_RECEIPT_LEXICON_JSON,
3402
+ lexicon.WORK_SCOPE_TAG_LEXICON_JSON,
2901
3403
  ];
2902
3404
  /**
2903
3405
  * Collection NSIDs (Namespaced Identifiers) for hypercert records.
@@ -2919,9 +3421,15 @@ const HYPERCERT_COLLECTIONS = {
2919
3421
  */
2920
3422
  LOCATION: lexicon.LOCATION_NSID,
2921
3423
  /**
2922
- * Contribution record collection.
3424
+ * Contribution details record collection.
3425
+ * For storing details about a specific contribution (role, description, timeframe).
2923
3426
  */
2924
- CONTRIBUTION: lexicon.CONTRIBUTION_NSID,
3427
+ CONTRIBUTION_DETAILS: lexicon.CONTRIBUTION_DETAILS_NSID,
3428
+ /**
3429
+ * Contributor information record collection.
3430
+ * For storing contributor profile information (identifier, displayName, image).
3431
+ */
3432
+ CONTRIBUTOR_INFORMATION: lexicon.CONTRIBUTOR_INFORMATION_NSID,
2925
3433
  /**
2926
3434
  * Measurement record collection.
2927
3435
  */
@@ -2936,12 +3444,9 @@ const HYPERCERT_COLLECTIONS = {
2936
3444
  EVIDENCE: lexicon.EVIDENCE_NSID,
2937
3445
  /**
2938
3446
  * Collection record collection (groups of hypercerts).
3447
+ * Projects are now collections with type='project'.
2939
3448
  */
2940
3449
  COLLECTION: lexicon.COLLECTION_NSID,
2941
- /**
2942
- * Project record collection.
2943
- */
2944
- PROJECT: lexicon.PROJECT_NSID,
2945
3450
  /**
2946
3451
  * Badge award record collection.
2947
3452
  */
@@ -2958,6 +3463,11 @@ const HYPERCERT_COLLECTIONS = {
2958
3463
  * Funding receipt record collection.
2959
3464
  */
2960
3465
  FUNDING_RECEIPT: lexicon.FUNDING_RECEIPT_NSID,
3466
+ /**
3467
+ * Work scope tag record collection.
3468
+ * For defining reusable work scope atoms.
3469
+ */
3470
+ WORK_SCOPE_TAG: lexicon.WORK_SCOPE_TAG_NSID,
2961
3471
  };
2962
3472
 
2963
3473
  /**
@@ -3051,6 +3561,27 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3051
3561
  }
3052
3562
  }
3053
3563
  }
3564
+ /**
3565
+ * Helper function to upload a blob to the repository, returns a blob reference
3566
+ *
3567
+ * @param content - Blob to upload
3568
+ * @param fallbackContentType | if content.type is empty,we use this
3569
+ * @returns BlobRef
3570
+ * @throws {@link NetworkError} if upload fails
3571
+ * @internal
3572
+ */
3573
+ async handleBlobUpload(content, fallbackContentType) {
3574
+ const arrayBuffer = await content.arrayBuffer();
3575
+ const uint8Array = new Uint8Array(arrayBuffer);
3576
+ const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
3577
+ encoding: content.type || fallbackContentType,
3578
+ qp: { repo: this.repoDid },
3579
+ });
3580
+ if (!uploadResult.success) {
3581
+ throw new NetworkError("Failed to upload blob");
3582
+ }
3583
+ return uploadResult.data.blob;
3584
+ }
3054
3585
  /**
3055
3586
  * Uploads an image blob and returns a blob reference.
3056
3587
  *
@@ -3067,6 +3598,7 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3067
3598
  const uint8Array = new Uint8Array(arrayBuffer);
3068
3599
  const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
3069
3600
  encoding: image.type || "image/jpeg",
3601
+ qp: { repo: this.repoDid },
3070
3602
  });
3071
3603
  if (uploadResult.success) {
3072
3604
  const blobRef = {
@@ -3161,9 +3693,6 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3161
3693
  if (imageBlobRef) {
3162
3694
  hypercertRecord.image = imageBlobRef;
3163
3695
  }
3164
- if (params.evidence && params.evidence.length > 0) {
3165
- hypercertRecord.evidence = params.evidence;
3166
- }
3167
3696
  const hypercertValidation = lexicon.validate(hypercertRecord, HYPERCERT_COLLECTIONS.CLAIM, "main", false);
3168
3697
  if (!hypercertValidation.success) {
3169
3698
  throw new ValidationError(`Invalid hypercert record: ${hypercertValidation.error?.message}`);
@@ -3247,6 +3776,35 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3247
3776
  throw error;
3248
3777
  }
3249
3778
  }
3779
+ /**
3780
+ * Creates evidence records with progress tracking.
3781
+ *
3782
+ * @param hypercertUri - URI of the hypercert
3783
+ * @param evidenceItems - Array of evidence data (without subjectUri)
3784
+ * @param onProgress - Optional progress callback
3785
+ * @returns Promise resolving to array of evidence URIs
3786
+ * @internal
3787
+ */
3788
+ async createEvidenceWithProgress(hypercertUri, evidenceItems, onProgress) {
3789
+ this.emitProgress(onProgress, { name: "addEvidence", status: "start" });
3790
+ try {
3791
+ const evidenceUris = await Promise.all(evidenceItems.map((evidence) => this.addEvidence({
3792
+ subjectUri: hypercertUri,
3793
+ ...evidence,
3794
+ }).then((result) => result.uri)));
3795
+ this.emitProgress(onProgress, {
3796
+ name: "addEvidence",
3797
+ status: "success",
3798
+ data: { count: evidenceUris.length },
3799
+ });
3800
+ return evidenceUris;
3801
+ }
3802
+ catch (error) {
3803
+ this.emitProgress(onProgress, { name: "addEvidence", status: "error", error: error });
3804
+ this.logger?.warn(`Failed to create evidence: ${error instanceof Error ? error.message : "Unknown"}`);
3805
+ throw error;
3806
+ }
3807
+ }
3250
3808
  /**
3251
3809
  * Creates a new hypercert with all related records.
3252
3810
  *
@@ -3275,6 +3833,7 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3275
3833
  * - `createHypercert`: Main hypercert record creation
3276
3834
  * - `attachLocation`: Location record creation
3277
3835
  * - `createContributions`: Contribution records creation
3836
+ * - `addEvidence`: Evidence records creation
3278
3837
  *
3279
3838
  * @example Minimal hypercert
3280
3839
  * ```typescript
@@ -3350,6 +3909,15 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3350
3909
  // Error already logged and progress emitted
3351
3910
  }
3352
3911
  }
3912
+ // Step 6: Add evidence records if provided
3913
+ if (params.evidence && params.evidence.length > 0) {
3914
+ try {
3915
+ result.evidenceUris = await this.createEvidenceWithProgress(hypercertUri, params.evidence, params.onProgress);
3916
+ }
3917
+ catch {
3918
+ // Error already logged and progress emitted
3919
+ }
3920
+ }
3353
3921
  return result;
3354
3922
  }
3355
3923
  catch (error) {
@@ -3632,50 +4200,18 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3632
4200
  */
3633
4201
  async attachLocation(hypercertUri, location) {
3634
4202
  try {
3635
- // Validate required srs field
3636
4203
  if (!location.srs) {
3637
4204
  throw new ValidationError("srs (Spatial Reference System) is required. Example: 'EPSG:4326' for WGS84 coordinates, or 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' for CRS84.");
3638
4205
  }
3639
4206
  // Validate that hypercert exists (unused but confirms hypercert is valid)
3640
4207
  await this.get(hypercertUri);
3641
4208
  const createdAt = new Date().toISOString();
3642
- // Determine location type and prepare location data
3643
- let locationData;
3644
- let locationType;
3645
- if (location.geojson) {
3646
- // Upload GeoJSON as a blob
3647
- const arrayBuffer = await location.geojson.arrayBuffer();
3648
- const uint8Array = new Uint8Array(arrayBuffer);
3649
- const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
3650
- encoding: location.geojson.type || "application/geo+json",
3651
- });
3652
- if (uploadResult.success) {
3653
- locationData = {
3654
- $type: "blob",
3655
- ref: { $link: uploadResult.data.blob.ref.toString() },
3656
- mimeType: uploadResult.data.blob.mimeType,
3657
- size: uploadResult.data.blob.size,
3658
- };
3659
- locationType = "geojson-point";
3660
- }
3661
- else {
3662
- throw new NetworkError("Failed to upload GeoJSON blob");
3663
- }
3664
- }
3665
- else {
3666
- // Use value as a URI reference
3667
- locationData = {
3668
- $type: "org.hypercerts.defs#uri",
3669
- uri: location.value,
3670
- };
3671
- locationType = "coordinate-decimal";
3672
- }
3673
- // Build location record according to app.certified.location lexicon
4209
+ const locationData = await this.resolveUriOrBlob(location.location, "application/geo+json");
3674
4210
  const locationRecord = {
3675
4211
  $type: HYPERCERT_COLLECTIONS.LOCATION,
3676
- lpVersion: "1.0",
4212
+ lpVersion: location.lpVersion || "1.0",
3677
4213
  srs: location.srs,
3678
- locationType,
4214
+ locationType: location.locationType,
3679
4215
  location: locationData,
3680
4216
  createdAt,
3681
4217
  name: location.name,
@@ -3693,6 +4229,16 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3693
4229
  if (!result.success) {
3694
4230
  throw new NetworkError("Failed to attach location");
3695
4231
  }
4232
+ await this.update({
4233
+ uri: hypercertUri,
4234
+ updates: {
4235
+ location: {
4236
+ $type: "com.atproto.repo.strongRef",
4237
+ uri: result.data.uri,
4238
+ cid: result.data.cid,
4239
+ },
4240
+ },
4241
+ });
3696
4242
  this.emit("locationAttached", { uri: result.data.uri, cid: result.data.cid, hypercertUri });
3697
4243
  return { uri: result.data.uri, cid: result.data.cid };
3698
4244
  }
@@ -3703,36 +4249,75 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3703
4249
  }
3704
4250
  }
3705
4251
  /**
3706
- * Adds evidence to an existing hypercert.
4252
+ * Generic helper to resolve string | Blob into a URI or blob reference.
4253
+ *
4254
+ * @param content - Either a URI string or a Blob to upload
4255
+ * @param fallbackMimeType - MIME type to use if Blob.type is empty
4256
+ * @returns Promise resolving to either a URI ref or blob ref union type
4257
+ * @internal
4258
+ */
4259
+ async resolveUriOrBlob(content, fallbackMimeType) {
4260
+ if (typeof content === "string") {
4261
+ return {
4262
+ $type: "org.hypercerts.defs#uri",
4263
+ uri: content,
4264
+ };
4265
+ }
4266
+ else {
4267
+ const uploadedBlob = await this.handleBlobUpload(content, fallbackMimeType);
4268
+ return {
4269
+ $type: "org.hypercerts.defs#smallBlob",
4270
+ blob: uploadedBlob,
4271
+ };
4272
+ }
4273
+ }
4274
+ /**
4275
+ * Adds evidence to any subject via the subject ref.
3707
4276
  *
3708
- * @param hypercertUri - AT-URI of the hypercert
3709
- * @param evidence - Array of evidence items to add
4277
+ * @param evidence - HypercertEvidenceInput
3710
4278
  * @returns Promise resolving to update result
3711
4279
  * @throws {@link ValidationError} if validation fails
3712
4280
  * @throws {@link NetworkError} if the operation fails
3713
4281
  *
3714
- * @remarks
3715
- * Evidence is appended to existing evidence, not replaced.
3716
- *
3717
4282
  * @example
3718
4283
  * ```typescript
3719
- * await repo.hypercerts.addEvidence(hypercertUri, [
3720
- * { uri: "https://example.com/report.pdf", description: "Impact report" },
3721
- * { uri: "https://example.com/data.csv", description: "Raw data" },
3722
- * ]);
4284
+ * await repo.hypercerts.addEvidence({
4285
+ * subjectUri: "at://did:plc:u7h3dstby64di67bxaotzxcz/org.hypercerts.claim.activity/3mbvv5d7ixh2g"
4286
+ * content: Blob,
4287
+ * title: "Meeting Notes",
4288
+ * shortDescription: "Meetings notes from the 3rd of December 2025",
4289
+ * description: "The meeting with the board of directors and audience on 2025 in regards to the ecological landscape",
4290
+ * relationType: "supports",
4291
+ * })
3723
4292
  * ```
3724
4293
  */
3725
- async addEvidence(hypercertUri, evidence) {
4294
+ async addEvidence(evidence) {
3726
4295
  try {
3727
- const existing = await this.get(hypercertUri);
3728
- const existingEvidence = existing.record.evidence || [];
3729
- const updatedEvidence = [...existingEvidence, ...evidence];
3730
- const result = await this.update({
3731
- uri: hypercertUri,
3732
- updates: { evidence: updatedEvidence },
4296
+ const { subjectUri, content, ...rest } = evidence;
4297
+ const subject = await this.get(subjectUri);
4298
+ const createdAt = new Date().toISOString();
4299
+ const evidenceContent = await this.resolveUriOrBlob(content, "application/octet-stream");
4300
+ const evidenceRecord = {
4301
+ ...rest,
4302
+ $type: HYPERCERT_COLLECTIONS.EVIDENCE,
4303
+ createdAt,
4304
+ content: evidenceContent,
4305
+ subject: { uri: subject.uri, cid: subject.cid },
4306
+ };
4307
+ const validation = lexicon.validate(evidenceRecord, HYPERCERT_COLLECTIONS.EVIDENCE, "main", false);
4308
+ if (!validation.success) {
4309
+ throw new ValidationError(`Invalid evidence record: ${validation.error?.message}`);
4310
+ }
4311
+ const result = await this.agent.com.atproto.repo.createRecord({
4312
+ repo: this.repoDid,
4313
+ collection: HYPERCERT_COLLECTIONS.EVIDENCE,
4314
+ record: evidenceRecord,
3733
4315
  });
3734
- this.emit("evidenceAdded", { uri: result.uri, cid: result.cid });
3735
- return result;
4316
+ if (!result.success) {
4317
+ throw new NetworkError(`Failed to add evidence`);
4318
+ }
4319
+ this.emit("evidenceAdded", { uri: result.data.uri, cid: result.data.cid });
4320
+ return { uri: result.data.uri, cid: result.data.cid };
3736
4321
  }
3737
4322
  catch (error) {
3738
4323
  if (error instanceof ValidationError || error instanceof NetworkError)
@@ -3741,22 +4326,29 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3741
4326
  }
3742
4327
  }
3743
4328
  /**
3744
- * Creates a contribution record.
4329
+ * Creates a contribution details record.
4330
+ *
4331
+ * This creates a standalone contribution details record that can be referenced
4332
+ * from an activity's `contributors` array via a strong reference.
3745
4333
  *
3746
4334
  * @param params - Contribution parameters
3747
- * @param params.hypercertUri - Optional hypercert to link (can be standalone)
3748
- * @param params.contributors - Array of contributor DIDs
3749
- * @param params.role - Role of the contributors (e.g., "coordinator", "implementer")
4335
+ * @param params.hypercertUri - Optional hypercert (unused, kept for backward compatibility)
4336
+ * @param params.contributors - Array of contributor DIDs (unused, kept for backward compatibility)
4337
+ * @param params.role - Role of the contributor (e.g., "coordinator", "implementer")
3750
4338
  * @param params.description - Optional description of the contribution
3751
- * @returns Promise resolving to contribution record URI and CID
4339
+ * @returns Promise resolving to contribution details record URI and CID
3752
4340
  * @throws {@link ValidationError} if validation fails
3753
4341
  * @throws {@link NetworkError} if the operation fails
3754
4342
  *
4343
+ * @remarks
4344
+ * In the new lexicon structure, contributions are stored differently:
4345
+ * - Use `contributionDetails` for detailed contribution records (role, description, timeframe)
4346
+ * - Use `contributorInformation` for contributor profiles (identifier, displayName, image)
4347
+ * - Reference these from the activity's `contributors` array using strong refs
4348
+ *
3755
4349
  * @example
3756
4350
  * ```typescript
3757
4351
  * await repo.hypercerts.addContribution({
3758
- * hypercertUri: hypercertUri,
3759
- * contributors: ["did:plc:alice", "did:plc:bob"],
3760
4352
  * role: "implementer",
3761
4353
  * description: "On-ground implementation team",
3762
4354
  * });
@@ -3766,28 +4358,22 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3766
4358
  try {
3767
4359
  const createdAt = new Date().toISOString();
3768
4360
  const contributionRecord = {
3769
- $type: HYPERCERT_COLLECTIONS.CONTRIBUTION,
3770
- contributors: params.contributors,
4361
+ $type: HYPERCERT_COLLECTIONS.CONTRIBUTION_DETAILS,
3771
4362
  role: params.role,
3772
4363
  createdAt,
3773
- description: params.description,
3774
- hypercert: { uri: "", cid: "" }, // Will be set below if hypercertUri provided
4364
+ contributionDescription: params.description,
3775
4365
  };
3776
- if (params.hypercertUri) {
3777
- const hypercert = await this.get(params.hypercertUri);
3778
- contributionRecord.hypercert = { uri: hypercert.uri, cid: hypercert.cid };
3779
- }
3780
- const validation = lexicon.validate(contributionRecord, HYPERCERT_COLLECTIONS.CONTRIBUTION, "main", false);
4366
+ const validation = lexicon.validate(contributionRecord, HYPERCERT_COLLECTIONS.CONTRIBUTION_DETAILS, "main", false);
3781
4367
  if (!validation.success) {
3782
- throw new ValidationError(`Invalid contribution record: ${validation.error?.message}`);
4368
+ throw new ValidationError(`Invalid contribution details record: ${validation.error?.message}`);
3783
4369
  }
3784
4370
  const result = await this.agent.com.atproto.repo.createRecord({
3785
4371
  repo: this.repoDid,
3786
- collection: HYPERCERT_COLLECTIONS.CONTRIBUTION,
4372
+ collection: HYPERCERT_COLLECTIONS.CONTRIBUTION_DETAILS,
3787
4373
  record: contributionRecord,
3788
4374
  });
3789
4375
  if (!result.success) {
3790
- throw new NetworkError("Failed to create contribution");
4376
+ throw new NetworkError("Failed to create contribution details");
3791
4377
  }
3792
4378
  this.emit("contributionCreated", { uri: result.data.uri, cid: result.data.cid });
3793
4379
  return { uri: result.data.uri, cid: result.data.cid };
@@ -3924,7 +4510,7 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3924
4510
  * @param params.title - Collection title
3925
4511
  * @param params.claims - Array of hypercert references with weights
3926
4512
  * @param params.shortDescription - Optional short description
3927
- * @param params.coverPhoto - Optional cover image blob
4513
+ * @param params.banner - Optional cover image blob
3928
4514
  * @returns Promise resolving to collection record URI and CID
3929
4515
  * @throws {@link ValidationError} if validation fails
3930
4516
  * @throws {@link NetworkError} if the operation fails
@@ -3939,22 +4525,22 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3939
4525
  * { uri: hypercert2Uri, cid: hypercert2Cid, weight: "0.3" },
3940
4526
  * { uri: hypercert3Uri, cid: hypercert3Cid, weight: "0.2" },
3941
4527
  * ],
3942
- * coverPhoto: coverImageBlob,
4528
+ * banner: coverImageBlob,
3943
4529
  * });
3944
4530
  * ```
3945
4531
  */
3946
4532
  async createCollection(params) {
3947
4533
  try {
3948
4534
  const createdAt = new Date().toISOString();
3949
- let coverPhotoRef;
3950
- if (params.coverPhoto) {
3951
- const arrayBuffer = await params.coverPhoto.arrayBuffer();
4535
+ let bannerRef;
4536
+ if (params.banner) {
4537
+ const arrayBuffer = await params.banner.arrayBuffer();
3952
4538
  const uint8Array = new Uint8Array(arrayBuffer);
3953
4539
  const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
3954
- encoding: params.coverPhoto.type || "image/jpeg",
4540
+ encoding: params.banner.type || "image/jpeg",
3955
4541
  });
3956
4542
  if (uploadResult.success) {
3957
- coverPhotoRef = {
4543
+ bannerRef = {
3958
4544
  $type: "blob",
3959
4545
  ref: { $link: uploadResult.data.blob.ref.toString() },
3960
4546
  mimeType: uploadResult.data.blob.mimeType,
@@ -3971,8 +4557,8 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3971
4557
  if (params.shortDescription) {
3972
4558
  collectionRecord.shortDescription = params.shortDescription;
3973
4559
  }
3974
- if (coverPhotoRef) {
3975
- collectionRecord.coverPhoto = coverPhotoRef;
4560
+ if (bannerRef) {
4561
+ collectionRecord.banner = bannerRef;
3976
4562
  }
3977
4563
  const validation = lexicon.validate(collectionRecord, HYPERCERT_COLLECTIONS.COLLECTION, "main", false);
3978
4564
  if (!validation.success) {
@@ -4083,190 +4669,606 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
4083
4669
  throw new NetworkError(`Failed to list collections: ${error instanceof Error ? error.message : "Unknown"}`, error);
4084
4670
  }
4085
4671
  }
4086
- }
4087
-
4088
- /**
4089
- * CollaboratorOperationsImpl - SDS collaborator management operations.
4090
- *
4091
- * This module provides the implementation for managing collaborator
4092
- * access on Shared Data Server (SDS) repositories.
4093
- *
4094
- * @packageDocumentation
4095
- */
4096
- /**
4097
- * Implementation of collaborator operations for SDS access control.
4098
- *
4099
- * This class manages access permissions for shared repositories on
4100
- * Shared Data Servers (SDS). It provides role-based access control
4101
- * with predefined permission sets.
4102
- *
4103
- * @remarks
4104
- * This class is typically not instantiated directly. Access it through
4105
- * {@link Repository.collaborators} on an SDS-connected repository.
4106
- *
4107
- * **Role Hierarchy**:
4108
- * - `viewer`: Read-only access
4109
- * - `editor`: Read + Create + Update
4110
- * - `admin`: All permissions except ownership transfer
4111
- * - `owner`: Full control including ownership management
4112
- *
4113
- * **SDS API Endpoints Used**:
4114
- * - `com.sds.repo.grantAccess`: Grant access to a user
4115
- * - `com.sds.repo.revokeAccess`: Revoke access from a user
4116
- * - `com.sds.repo.listCollaborators`: List all collaborators
4117
- * - `com.sds.repo.getPermissions`: Get current user's permissions
4118
- * - `com.sds.repo.transferOwnership`: Transfer repository ownership
4119
- *
4120
- * @example
4121
- * ```typescript
4122
- * // Get SDS repository
4123
- * const sdsRepo = sdk.repository(session, { server: "sds" });
4124
- *
4125
- * // Grant editor access
4126
- * await sdsRepo.collaborators.grant({
4127
- * userDid: "did:plc:new-user",
4128
- * role: "editor",
4129
- * });
4130
- *
4131
- * // List all collaborators
4132
- * const collaborators = await sdsRepo.collaborators.list();
4133
- *
4134
- * // Check specific user
4135
- * const hasAccess = await sdsRepo.collaborators.hasAccess("did:plc:someone");
4136
- * const role = await sdsRepo.collaborators.getRole("did:plc:someone");
4137
- * ```
4138
- *
4139
- * @internal
4140
- */
4141
- class CollaboratorOperationsImpl {
4142
- /**
4143
- * Creates a new CollaboratorOperationsImpl.
4144
- *
4145
- * @param session - Authenticated OAuth session with fetchHandler
4146
- * @param repoDid - DID of the repository to manage
4147
- * @param serverUrl - SDS server URL
4148
- *
4149
- * @internal
4150
- */
4151
- constructor(session, repoDid, serverUrl) {
4152
- this.session = session;
4153
- this.repoDid = repoDid;
4154
- this.serverUrl = serverUrl;
4155
- }
4156
- /**
4157
- * Converts a role to its corresponding permissions object.
4158
- *
4159
- * @param role - The role to convert
4160
- * @returns Permission flags for the role
4161
- * @internal
4162
- */
4163
- roleToPermissions(role) {
4164
- switch (role) {
4165
- case "viewer":
4166
- return { read: true, create: false, update: false, delete: false, admin: false, owner: false };
4167
- case "editor":
4168
- return { read: true, create: true, update: true, delete: false, admin: false, owner: false };
4169
- case "admin":
4170
- return { read: true, create: true, update: true, delete: true, admin: true, owner: false };
4171
- case "owner":
4172
- return { read: true, create: true, update: true, delete: true, admin: true, owner: true };
4173
- }
4174
- }
4175
- /**
4176
- * Determines the role from a permissions object.
4177
- *
4178
- * @param permissions - The permissions to analyze
4179
- * @returns The highest role matching the permissions
4180
- * @internal
4181
- */
4182
- permissionsToRole(permissions) {
4183
- if (permissions.owner)
4184
- return "owner";
4185
- if (permissions.admin)
4186
- return "admin";
4187
- if (permissions.create || permissions.update)
4188
- return "editor";
4189
- return "viewer";
4190
- }
4191
- /**
4192
- * Normalizes permissions from SDS API format to SDK format.
4193
- *
4194
- * The SDS API returns permissions as an object with boolean flags
4195
- * (e.g., `{ read: true, create: true, update: false, ... }`).
4196
- * This method ensures all expected fields are present with default values.
4197
- *
4198
- * @param permissions - Permissions object from SDS API
4199
- * @returns Normalized permission flags object
4200
- * @internal
4201
- */
4202
- parsePermissions(permissions) {
4203
- return {
4204
- read: permissions.read ?? false,
4205
- create: permissions.create ?? false,
4206
- update: permissions.update ?? false,
4207
- delete: permissions.delete ?? false,
4208
- admin: permissions.admin ?? false,
4209
- owner: permissions.owner ?? false,
4210
- };
4211
- }
4212
4672
  /**
4213
- * Grants repository access to a user.
4673
+ * Creates a new project that organizes multiple hypercert activities.
4214
4674
  *
4215
- * @param params - Grant parameters
4216
- * @param params.userDid - DID of the user to grant access to
4217
- * @param params.role - Role to assign (determines permissions)
4218
- * @throws {@link NetworkError} if the grant operation fails
4675
+ * Projects are now implemented as collections with `type='project'`.
4219
4676
  *
4220
- * @remarks
4221
- * If the user already has access, their permissions are updated
4222
- * to the new role.
4677
+ * @param params - Project creation parameters
4678
+ * @returns Promise resolving to created project URI and CID
4223
4679
  *
4224
4680
  * @example
4225
4681
  * ```typescript
4226
- * // Grant viewer access
4227
- * await repo.collaborators.grant({
4228
- * userDid: "did:plc:viewer-user",
4229
- * role: "viewer",
4230
- * });
4231
- *
4232
- * // Upgrade to editor
4233
- * await repo.collaborators.grant({
4234
- * userDid: "did:plc:viewer-user",
4235
- * role: "editor",
4682
+ * const result = await repo.hypercerts.createProject({
4683
+ * title: "Climate Impact 2024",
4684
+ * shortDescription: "Year-long climate initiative",
4685
+ * activities: [
4686
+ * { uri: activity1Uri, cid: activity1Cid, weight: "0.6" },
4687
+ * { uri: activity2Uri, cid: activity2Cid, weight: "0.4" }
4688
+ * ]
4236
4689
  * });
4690
+ * console.log(`Created project: ${result.uri}`);
4237
4691
  * ```
4238
4692
  */
4239
- async grant(params) {
4240
- const permissions = this.roleToPermissions(params.role);
4241
- const response = await this.session.fetchHandler(`${this.serverUrl}/xrpc/com.sds.repo.grantAccess`, {
4242
- method: "POST",
4243
- headers: { "Content-Type": "application/json" },
4244
- body: JSON.stringify({
4693
+ async createProject(params) {
4694
+ try {
4695
+ const createdAt = new Date().toISOString();
4696
+ // Upload avatar blob if provided
4697
+ let avatarRef;
4698
+ if (params.avatar) {
4699
+ const arrayBuffer = await params.avatar.arrayBuffer();
4700
+ const uint8Array = new Uint8Array(arrayBuffer);
4701
+ const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
4702
+ encoding: params.avatar.type || "image/jpeg",
4703
+ });
4704
+ if (uploadResult.success) {
4705
+ avatarRef = {
4706
+ $type: "blob",
4707
+ ref: { $link: uploadResult.data.blob.ref.toString() },
4708
+ mimeType: uploadResult.data.blob.mimeType,
4709
+ size: uploadResult.data.blob.size,
4710
+ };
4711
+ }
4712
+ else {
4713
+ throw new NetworkError("Failed to upload avatar image");
4714
+ }
4715
+ }
4716
+ // Upload banner blob if provided
4717
+ let bannerRef;
4718
+ if (params.banner) {
4719
+ const arrayBuffer = await params.banner.arrayBuffer();
4720
+ const uint8Array = new Uint8Array(arrayBuffer);
4721
+ const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
4722
+ encoding: params.banner.type || "image/jpeg",
4723
+ });
4724
+ if (uploadResult.success) {
4725
+ bannerRef = {
4726
+ $type: "blob",
4727
+ ref: { $link: uploadResult.data.blob.ref.toString() },
4728
+ mimeType: uploadResult.data.blob.mimeType,
4729
+ size: uploadResult.data.blob.size,
4730
+ };
4731
+ }
4732
+ else {
4733
+ throw new NetworkError("Failed to upload banner image");
4734
+ }
4735
+ }
4736
+ // Build project record as a collection with type='project'
4737
+ // Collections require 'items' array, so we map activities to items
4738
+ const items = params.activities?.map((a) => ({
4739
+ itemIdentifier: { uri: a.uri, cid: a.cid },
4740
+ itemWeight: a.weight,
4741
+ })) || [];
4742
+ const projectRecord = {
4743
+ $type: HYPERCERT_COLLECTIONS.COLLECTION,
4744
+ type: "project",
4745
+ title: params.title,
4746
+ shortDescription: params.shortDescription,
4747
+ items,
4748
+ createdAt,
4749
+ };
4750
+ // Add optional fields
4751
+ if (params.description) {
4752
+ projectRecord.description = params.description;
4753
+ }
4754
+ if (avatarRef) {
4755
+ projectRecord.avatar = avatarRef;
4756
+ }
4757
+ if (bannerRef) {
4758
+ projectRecord.banner = bannerRef;
4759
+ }
4760
+ // Validate against collection lexicon
4761
+ const validation = lexicon.validate(projectRecord, HYPERCERT_COLLECTIONS.COLLECTION, "main", false);
4762
+ if (!validation.success) {
4763
+ throw new ValidationError(`Invalid project record: ${validation.error?.message}`);
4764
+ }
4765
+ // Create record
4766
+ const result = await this.agent.com.atproto.repo.createRecord({
4245
4767
  repo: this.repoDid,
4246
- userDid: params.userDid,
4247
- permissions,
4248
- }),
4249
- });
4250
- if (!response.ok) {
4251
- throw new NetworkError(`Failed to grant access: ${response.statusText}`);
4768
+ collection: HYPERCERT_COLLECTIONS.COLLECTION,
4769
+ record: projectRecord,
4770
+ });
4771
+ if (!result.success) {
4772
+ throw new NetworkError("Failed to create project");
4773
+ }
4774
+ // Emit event
4775
+ this.emit("projectCreated", { uri: result.data.uri, cid: result.data.cid });
4776
+ return { uri: result.data.uri, cid: result.data.cid };
4777
+ }
4778
+ catch (error) {
4779
+ if (error instanceof ValidationError || error instanceof NetworkError)
4780
+ throw error;
4781
+ throw new NetworkError(`Failed to create project: ${error instanceof Error ? error.message : "Unknown"}`, error);
4252
4782
  }
4253
4783
  }
4254
4784
  /**
4255
- * Revokes repository access from a user.
4785
+ * Gets a project by its AT-URI.
4256
4786
  *
4257
- * @param params - Revoke parameters
4258
- * @param params.userDid - DID of the user to revoke access from
4259
- * @throws {@link NetworkError} if the revoke operation fails
4787
+ * Projects are collections with `type='project'`.
4260
4788
  *
4261
- * @remarks
4262
- * - Cannot revoke access from the repository owner
4263
- * - Revoked access is recorded with a `revokedAt` timestamp
4789
+ * @param uri - AT-URI of the project
4790
+ * @returns Promise resolving to project data (as collection)
4264
4791
  *
4265
4792
  * @example
4266
4793
  * ```typescript
4267
- * await repo.collaborators.revoke({
4268
- * userDid: "did:plc:former-collaborator",
4269
- * });
4794
+ * const { record } = await repo.hypercerts.getProject(projectUri);
4795
+ * console.log(`${record.title}: ${record.items?.length || 0} activities`);
4796
+ * ```
4797
+ */
4798
+ async getProject(uri) {
4799
+ try {
4800
+ // Parse URI
4801
+ const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
4802
+ if (!uriMatch) {
4803
+ throw new ValidationError(`Invalid URI format: ${uri}`);
4804
+ }
4805
+ const [, , collection, rkey] = uriMatch;
4806
+ // Fetch record
4807
+ const result = await this.agent.com.atproto.repo.getRecord({
4808
+ repo: this.repoDid,
4809
+ collection,
4810
+ rkey,
4811
+ });
4812
+ if (!result.success) {
4813
+ throw new NetworkError("Failed to get project");
4814
+ }
4815
+ // Validate as collection
4816
+ const validation = lexicon.validate(result.data.value, HYPERCERT_COLLECTIONS.COLLECTION, "main", false);
4817
+ if (!validation.success) {
4818
+ throw new ValidationError(`Invalid project record format: ${validation.error?.message}`);
4819
+ }
4820
+ // Verify it's actually a project (collection with type='project')
4821
+ const record = result.data.value;
4822
+ if (record.type !== "project") {
4823
+ throw new ValidationError(`Record is not a project (type='${record.type}')`);
4824
+ }
4825
+ return {
4826
+ uri: result.data.uri,
4827
+ cid: result.data.cid ?? "",
4828
+ record,
4829
+ };
4830
+ }
4831
+ catch (error) {
4832
+ if (error instanceof ValidationError || error instanceof NetworkError)
4833
+ throw error;
4834
+ throw new NetworkError(`Failed to get project: ${error instanceof Error ? error.message : "Unknown"}`, error);
4835
+ }
4836
+ }
4837
+ /**
4838
+ * Lists all projects with optional pagination.
4839
+ *
4840
+ * Projects are collections with `type='project'`. This method filters
4841
+ * collections to only return those with type='project'.
4842
+ *
4843
+ * @param params - Optional pagination parameters
4844
+ * @returns Promise resolving to paginated list of projects
4845
+ *
4846
+ * @example
4847
+ * ```typescript
4848
+ * const { records } = await repo.hypercerts.listProjects();
4849
+ * for (const { record } of records) {
4850
+ * console.log(`${record.title}: ${record.shortDescription}`);
4851
+ * }
4852
+ * ```
4853
+ */
4854
+ async listProjects(params) {
4855
+ try {
4856
+ const limit = params?.limit;
4857
+ let cursor = params?.cursor;
4858
+ const allRecords = [];
4859
+ // Loop-fetch until we have enough projects or no more cursor
4860
+ while (!cursor || allRecords.length < (limit ?? Infinity)) {
4861
+ const result = await this.agent.com.atproto.repo.listRecords({
4862
+ repo: this.repoDid,
4863
+ collection: HYPERCERT_COLLECTIONS.COLLECTION,
4864
+ limit: limit ?? 50,
4865
+ cursor,
4866
+ });
4867
+ if (!result.success) {
4868
+ throw new NetworkError("Failed to list projects");
4869
+ }
4870
+ // Filter and collect project records
4871
+ for (const r of result.data.records ?? []) {
4872
+ const record = r.value;
4873
+ if (record.type === "project") {
4874
+ allRecords.push({ uri: r.uri, cid: r.cid, record });
4875
+ }
4876
+ // Stop if we've collected enough
4877
+ if (limit && allRecords.length >= limit)
4878
+ break;
4879
+ }
4880
+ // Update cursor; break if no more pages
4881
+ cursor = result.data.cursor;
4882
+ if (!cursor)
4883
+ break;
4884
+ }
4885
+ return { records: allRecords, cursor };
4886
+ }
4887
+ catch (error) {
4888
+ if (error instanceof NetworkError)
4889
+ throw error;
4890
+ throw new NetworkError(`Failed to list projects: ${error instanceof Error ? error.message : "Unknown"}`, error);
4891
+ }
4892
+ }
4893
+ /**
4894
+ * Updates an existing project.
4895
+ *
4896
+ * Projects are collections with `type='project'`.
4897
+ *
4898
+ * @param uri - AT-URI of the project to update
4899
+ * @param updates - Fields to update
4900
+ * @returns Promise resolving to updated project URI and CID
4901
+ *
4902
+ * @example
4903
+ * ```typescript
4904
+ * const result = await repo.hypercerts.updateProject(projectUri, {
4905
+ * title: "Updated Project Title",
4906
+ * shortDescription: "New description"
4907
+ * });
4908
+ * ```
4909
+ */
4910
+ async updateProject(uri, updates) {
4911
+ try {
4912
+ // Parse URI and fetch existing record
4913
+ const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
4914
+ if (!uriMatch) {
4915
+ throw new ValidationError(`Invalid URI format: ${uri}`);
4916
+ }
4917
+ const [, , collection, rkey] = uriMatch;
4918
+ const existing = await this.agent.com.atproto.repo.getRecord({
4919
+ repo: this.repoDid,
4920
+ collection,
4921
+ rkey,
4922
+ });
4923
+ if (!existing.success) {
4924
+ throw new NetworkError(`Project not found: ${uri}`);
4925
+ }
4926
+ const existingRecord = existing.data.value;
4927
+ // Verify it's actually a project
4928
+ if (existingRecord.type !== "project") {
4929
+ throw new ValidationError(`Record is not a project (type='${existingRecord.type}')`);
4930
+ }
4931
+ // Merge updates with existing record
4932
+ const recordForUpdate = {
4933
+ ...existingRecord,
4934
+ // MUST preserve type, createdAt, and items structure
4935
+ type: "project",
4936
+ createdAt: existingRecord.createdAt,
4937
+ };
4938
+ // Apply simple field updates
4939
+ if (updates.title !== undefined)
4940
+ recordForUpdate.title = updates.title;
4941
+ if (updates.shortDescription !== undefined)
4942
+ recordForUpdate.shortDescription = updates.shortDescription;
4943
+ if (updates.description !== undefined)
4944
+ recordForUpdate.description = updates.description;
4945
+ // Handle avatar update with three-way logic
4946
+ delete recordForUpdate.avatar;
4947
+ if (updates.avatar !== undefined) {
4948
+ if (updates.avatar === null) {
4949
+ // Remove avatar
4950
+ }
4951
+ else {
4952
+ const arrayBuffer = await updates.avatar.arrayBuffer();
4953
+ const uint8Array = new Uint8Array(arrayBuffer);
4954
+ const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
4955
+ encoding: updates.avatar.type || "image/jpeg",
4956
+ });
4957
+ if (uploadResult.success) {
4958
+ recordForUpdate.avatar = {
4959
+ $type: "blob",
4960
+ ref: uploadResult.data.blob.ref,
4961
+ mimeType: uploadResult.data.blob.mimeType,
4962
+ size: uploadResult.data.blob.size,
4963
+ };
4964
+ }
4965
+ else {
4966
+ throw new NetworkError("Failed to upload avatar image");
4967
+ }
4968
+ }
4969
+ }
4970
+ else if (existingRecord.avatar) {
4971
+ recordForUpdate.avatar = existingRecord.avatar;
4972
+ }
4973
+ // Handle banner update
4974
+ delete recordForUpdate.banner;
4975
+ if (updates.banner !== undefined) {
4976
+ if (updates.banner === null) {
4977
+ // Remove banner
4978
+ }
4979
+ else {
4980
+ const arrayBuffer = await updates.banner.arrayBuffer();
4981
+ const uint8Array = new Uint8Array(arrayBuffer);
4982
+ const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
4983
+ encoding: updates.banner.type || "image/jpeg",
4984
+ });
4985
+ if (uploadResult.success) {
4986
+ recordForUpdate.banner = {
4987
+ $type: "blob",
4988
+ ref: uploadResult.data.blob.ref,
4989
+ mimeType: uploadResult.data.blob.mimeType,
4990
+ size: uploadResult.data.blob.size,
4991
+ };
4992
+ }
4993
+ else {
4994
+ throw new NetworkError("Failed to upload banner image");
4995
+ }
4996
+ }
4997
+ }
4998
+ else if (existingRecord.banner) {
4999
+ recordForUpdate.banner = existingRecord.banner;
5000
+ }
5001
+ // Transform activities to items array
5002
+ if (updates.activities) {
5003
+ recordForUpdate.items = updates.activities.map((a) => ({
5004
+ itemIdentifier: { uri: a.uri, cid: a.cid },
5005
+ itemWeight: a.weight,
5006
+ }));
5007
+ }
5008
+ else {
5009
+ // Preserve existing items
5010
+ recordForUpdate.items = existingRecord.items;
5011
+ }
5012
+ // Validate merged record
5013
+ const validation = lexicon.validate(recordForUpdate, HYPERCERT_COLLECTIONS.COLLECTION, "main", false);
5014
+ if (!validation.success) {
5015
+ throw new ValidationError(`Invalid project record: ${validation.error?.message}`);
5016
+ }
5017
+ // Update record
5018
+ const result = await this.agent.com.atproto.repo.putRecord({
5019
+ repo: this.repoDid,
5020
+ collection,
5021
+ rkey,
5022
+ record: recordForUpdate,
5023
+ });
5024
+ if (!result.success) {
5025
+ throw new NetworkError("Failed to update project");
5026
+ }
5027
+ // Emit event
5028
+ this.emit("projectUpdated", { uri: result.data.uri, cid: result.data.cid });
5029
+ return { uri: result.data.uri, cid: result.data.cid };
5030
+ }
5031
+ catch (error) {
5032
+ if (error instanceof ValidationError || error instanceof NetworkError)
5033
+ throw error;
5034
+ throw new NetworkError(`Failed to update project: ${error instanceof Error ? error.message : "Unknown"}`, error);
5035
+ }
5036
+ }
5037
+ /**
5038
+ * Deletes a project.
5039
+ *
5040
+ * Projects are collections with `type='project'`.
5041
+ *
5042
+ * @param uri - AT-URI of the project to delete
5043
+ *
5044
+ * @example
5045
+ * ```typescript
5046
+ * await repo.hypercerts.deleteProject(projectUri);
5047
+ * console.log("Project deleted");
5048
+ * ```
5049
+ */
5050
+ async deleteProject(uri) {
5051
+ try {
5052
+ // Parse URI
5053
+ const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
5054
+ if (!uriMatch) {
5055
+ throw new ValidationError(`Invalid URI format: ${uri}`);
5056
+ }
5057
+ const [, , collection, rkey] = uriMatch;
5058
+ // Verify it's actually a project before deleting
5059
+ const existing = await this.agent.com.atproto.repo.getRecord({
5060
+ repo: this.repoDid,
5061
+ collection,
5062
+ rkey,
5063
+ });
5064
+ if (existing.success) {
5065
+ const record = existing.data.value;
5066
+ if (record.type !== "project") {
5067
+ throw new ValidationError(`Record is not a project (type='${record.type}')`);
5068
+ }
5069
+ }
5070
+ // Delete record
5071
+ const result = await this.agent.com.atproto.repo.deleteRecord({
5072
+ repo: this.repoDid,
5073
+ collection,
5074
+ rkey,
5075
+ });
5076
+ if (!result.success) {
5077
+ throw new NetworkError("Failed to delete project");
5078
+ }
5079
+ // Emit event
5080
+ this.emit("projectDeleted", { uri });
5081
+ }
5082
+ catch (error) {
5083
+ if (error instanceof ValidationError || error instanceof NetworkError)
5084
+ throw error;
5085
+ throw new NetworkError(`Failed to delete project: ${error instanceof Error ? error.message : "Unknown"}`, error);
5086
+ }
5087
+ }
5088
+ }
5089
+
5090
+ /**
5091
+ * CollaboratorOperationsImpl - SDS collaborator management operations.
5092
+ *
5093
+ * This module provides the implementation for managing collaborator
5094
+ * access on Shared Data Server (SDS) repositories.
5095
+ *
5096
+ * @packageDocumentation
5097
+ */
5098
+ /**
5099
+ * Implementation of collaborator operations for SDS access control.
5100
+ *
5101
+ * This class manages access permissions for shared repositories on
5102
+ * Shared Data Servers (SDS). It provides role-based access control
5103
+ * with predefined permission sets.
5104
+ *
5105
+ * @remarks
5106
+ * This class is typically not instantiated directly. Access it through
5107
+ * {@link Repository.collaborators} on an SDS-connected repository.
5108
+ *
5109
+ * **Role Hierarchy**:
5110
+ * - `viewer`: Read-only access
5111
+ * - `editor`: Read + Create + Update
5112
+ * - `admin`: All permissions except ownership transfer
5113
+ * - `owner`: Full control including ownership management
5114
+ *
5115
+ * **SDS API Endpoints Used**:
5116
+ * - `com.sds.repo.grantAccess`: Grant access to a user
5117
+ * - `com.sds.repo.revokeAccess`: Revoke access from a user
5118
+ * - `com.sds.repo.listCollaborators`: List all collaborators
5119
+ * - `com.sds.repo.getPermissions`: Get current user's permissions
5120
+ * - `com.sds.repo.transferOwnership`: Transfer repository ownership
5121
+ *
5122
+ * @example
5123
+ * ```typescript
5124
+ * // Get SDS repository
5125
+ * const sdsRepo = sdk.repository(session, { server: "sds" });
5126
+ *
5127
+ * // Grant editor access
5128
+ * await sdsRepo.collaborators.grant({
5129
+ * userDid: "did:plc:new-user",
5130
+ * role: "editor",
5131
+ * });
5132
+ *
5133
+ * // List all collaborators
5134
+ * const collaborators = await sdsRepo.collaborators.list();
5135
+ *
5136
+ * // Check specific user
5137
+ * const hasAccess = await sdsRepo.collaborators.hasAccess("did:plc:someone");
5138
+ * const role = await sdsRepo.collaborators.getRole("did:plc:someone");
5139
+ * ```
5140
+ *
5141
+ * @internal
5142
+ */
5143
+ class CollaboratorOperationsImpl {
5144
+ /**
5145
+ * Creates a new CollaboratorOperationsImpl.
5146
+ *
5147
+ * @param session - Authenticated OAuth session with fetchHandler
5148
+ * @param repoDid - DID of the repository to manage
5149
+ * @param serverUrl - SDS server URL
5150
+ *
5151
+ * @internal
5152
+ */
5153
+ constructor(session, repoDid, serverUrl) {
5154
+ this.session = session;
5155
+ this.repoDid = repoDid;
5156
+ this.serverUrl = serverUrl;
5157
+ }
5158
+ /**
5159
+ * Converts a role to its corresponding permissions object.
5160
+ *
5161
+ * @param role - The role to convert
5162
+ * @returns Permission flags for the role
5163
+ * @internal
5164
+ */
5165
+ roleToPermissions(role) {
5166
+ switch (role) {
5167
+ case "viewer":
5168
+ return { read: true, create: false, update: false, delete: false, admin: false, owner: false };
5169
+ case "editor":
5170
+ return { read: true, create: true, update: true, delete: false, admin: false, owner: false };
5171
+ case "admin":
5172
+ return { read: true, create: true, update: true, delete: true, admin: true, owner: false };
5173
+ case "owner":
5174
+ return { read: true, create: true, update: true, delete: true, admin: true, owner: true };
5175
+ }
5176
+ }
5177
+ /**
5178
+ * Determines the role from a permissions object.
5179
+ *
5180
+ * @param permissions - The permissions to analyze
5181
+ * @returns The highest role matching the permissions
5182
+ * @internal
5183
+ */
5184
+ permissionsToRole(permissions) {
5185
+ if (permissions.owner)
5186
+ return "owner";
5187
+ if (permissions.admin)
5188
+ return "admin";
5189
+ if (permissions.create || permissions.update)
5190
+ return "editor";
5191
+ return "viewer";
5192
+ }
5193
+ /**
5194
+ * Normalizes permissions from SDS API format to SDK format.
5195
+ *
5196
+ * The SDS API returns permissions as an object with boolean flags
5197
+ * (e.g., `{ read: true, create: true, update: false, ... }`).
5198
+ * This method ensures all expected fields are present with default values.
5199
+ *
5200
+ * @param permissions - Permissions object from SDS API
5201
+ * @returns Normalized permission flags object
5202
+ * @internal
5203
+ */
5204
+ parsePermissions(permissions) {
5205
+ return {
5206
+ read: permissions.read ?? false,
5207
+ create: permissions.create ?? false,
5208
+ update: permissions.update ?? false,
5209
+ delete: permissions.delete ?? false,
5210
+ admin: permissions.admin ?? false,
5211
+ owner: permissions.owner ?? false,
5212
+ };
5213
+ }
5214
+ /**
5215
+ * Grants repository access to a user.
5216
+ *
5217
+ * @param params - Grant parameters
5218
+ * @param params.userDid - DID of the user to grant access to
5219
+ * @param params.role - Role to assign (determines permissions)
5220
+ * @throws {@link NetworkError} if the grant operation fails
5221
+ *
5222
+ * @remarks
5223
+ * If the user already has access, their permissions are updated
5224
+ * to the new role.
5225
+ *
5226
+ * @example
5227
+ * ```typescript
5228
+ * // Grant viewer access
5229
+ * await repo.collaborators.grant({
5230
+ * userDid: "did:plc:viewer-user",
5231
+ * role: "viewer",
5232
+ * });
5233
+ *
5234
+ * // Upgrade to editor
5235
+ * await repo.collaborators.grant({
5236
+ * userDid: "did:plc:viewer-user",
5237
+ * role: "editor",
5238
+ * });
5239
+ * ```
5240
+ */
5241
+ async grant(params) {
5242
+ const permissions = this.roleToPermissions(params.role);
5243
+ const response = await this.session.fetchHandler(`${this.serverUrl}/xrpc/com.sds.repo.grantAccess`, {
5244
+ method: "POST",
5245
+ headers: { "Content-Type": "application/json" },
5246
+ body: JSON.stringify({
5247
+ repo: this.repoDid,
5248
+ userDid: params.userDid,
5249
+ permissions,
5250
+ }),
5251
+ });
5252
+ if (!response.ok) {
5253
+ throw new NetworkError(`Failed to grant access: ${response.statusText}`);
5254
+ }
5255
+ }
5256
+ /**
5257
+ * Revokes repository access from a user.
5258
+ *
5259
+ * @param params - Revoke parameters
5260
+ * @param params.userDid - DID of the user to revoke access from
5261
+ * @throws {@link NetworkError} if the revoke operation fails
5262
+ *
5263
+ * @remarks
5264
+ * - Cannot revoke access from the repository owner
5265
+ * - Revoked access is recorded with a `revokedAt` timestamp
5266
+ *
5267
+ * @example
5268
+ * ```typescript
5269
+ * await repo.collaborators.revoke({
5270
+ * userDid: "did:plc:former-collaborator",
5271
+ * });
4270
5272
  * ```
4271
5273
  */
4272
5274
  async revoke(params) {
@@ -4833,6 +5835,7 @@ class Repository {
4833
5835
  * @param repoDid - DID of the repository to operate on
4834
5836
  * @param isSDS - Whether this is a Shared Data Server
4835
5837
  * @param logger - Optional logger for debugging
5838
+ * @param lexiconRegistry - Registry for custom lexicon management
4836
5839
  *
4837
5840
  * @remarks
4838
5841
  * This constructor is typically not called directly. Use
@@ -4840,12 +5843,13 @@ class Repository {
4840
5843
  *
4841
5844
  * @internal
4842
5845
  */
4843
- constructor(session, serverUrl, repoDid, isSDS, logger) {
5846
+ constructor(session, serverUrl, repoDid, isSDS, logger, lexiconRegistry) {
4844
5847
  this.session = session;
4845
5848
  this.serverUrl = serverUrl;
4846
5849
  this.repoDid = repoDid;
4847
5850
  this._isSDS = isSDS;
4848
5851
  this.logger = logger;
5852
+ this.lexiconRegistry = lexiconRegistry || new LexiconRegistry();
4849
5853
  // Create a ConfigurableAgent that routes requests to the specified server URL
4850
5854
  // This allows routing to PDS, SDS, or any custom server while maintaining
4851
5855
  // the OAuth session's authentication
@@ -4920,7 +5924,53 @@ class Repository {
4920
5924
  * ```
4921
5925
  */
4922
5926
  repo(did) {
4923
- return new Repository(this.session, this.serverUrl, did, this._isSDS, this.logger);
5927
+ return new Repository(this.session, this.serverUrl, did, this._isSDS, this.logger, this.lexiconRegistry);
5928
+ }
5929
+ /**
5930
+ * Gets the LexiconRegistry instance for managing custom lexicons.
5931
+ *
5932
+ * The registry is shared across all operations in this repository and
5933
+ * enables validation of custom record types.
5934
+ *
5935
+ * @returns The {@link LexiconRegistry} instance
5936
+ *
5937
+ * @example
5938
+ * ```typescript
5939
+ * // Access the registry
5940
+ * const registry = repo.getLexiconRegistry();
5941
+ *
5942
+ * // Register a custom lexicon
5943
+ * registry.register({
5944
+ * lexicon: 1,
5945
+ * id: "org.myapp.evaluation",
5946
+ * defs: {
5947
+ * main: {
5948
+ * type: "record",
5949
+ * key: "tid",
5950
+ * record: {
5951
+ * type: "object",
5952
+ * required: ["$type", "score"],
5953
+ * properties: {
5954
+ * "$type": { type: "string", const: "org.myapp.evaluation" },
5955
+ * score: { type: "integer", minimum: 0, maximum: 100 }
5956
+ * }
5957
+ * }
5958
+ * }
5959
+ * }
5960
+ * });
5961
+ *
5962
+ * // Now create records using the custom lexicon
5963
+ * await repo.records.create({
5964
+ * collection: "org.myapp.evaluation",
5965
+ * record: {
5966
+ * $type: "org.myapp.evaluation",
5967
+ * score: 85
5968
+ * }
5969
+ * });
5970
+ * ```
5971
+ */
5972
+ getLexiconRegistry() {
5973
+ return this.lexiconRegistry;
4924
5974
  }
4925
5975
  /**
4926
5976
  * Low-level record operations for CRUD on any AT Protocol record type.
@@ -4953,7 +6003,7 @@ class Repository {
4953
6003
  */
4954
6004
  get records() {
4955
6005
  if (!this._records) {
4956
- this._records = new RecordOperationsImpl(this.agent, this.repoDid);
6006
+ this._records = new RecordOperationsImpl(this.agent, this.repoDid, this.lexiconRegistry);
4957
6007
  }
4958
6008
  return this._records;
4959
6009
  }
@@ -5130,26 +6180,67 @@ class Repository {
5130
6180
  }
5131
6181
  }
5132
6182
 
6183
+ /**
6184
+ * Custom URL validator that allows HTTP loopback addresses for development.
6185
+ *
6186
+ * Accepts:
6187
+ * - Any HTTPS URL (production)
6188
+ * - http://localhost (with optional port and path)
6189
+ * - http://127.0.0.1 (with optional port and path)
6190
+ * - http://[::1] (with optional port and path) - IPv6 loopback
6191
+ *
6192
+ * Rejects:
6193
+ * - Other HTTP URLs (e.g., http://example.com)
6194
+ * - Invalid URLs
6195
+ *
6196
+ * @internal
6197
+ */
6198
+ const urlOrLoopback = zod.z.string().refine((value) => {
6199
+ try {
6200
+ const url = new URL(value);
6201
+ // Always allow HTTPS
6202
+ if (url.protocol === "https:") {
6203
+ return true;
6204
+ }
6205
+ // For HTTP, only allow loopback addresses
6206
+ if (url.protocol === "http:") {
6207
+ const hostname = url.hostname.toLowerCase();
6208
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
6209
+ }
6210
+ return false;
6211
+ }
6212
+ catch {
6213
+ return false;
6214
+ }
6215
+ }, {
6216
+ message: "Must be a valid HTTPS URL or HTTP loopback URL (localhost, 127.0.0.1, [::1])",
6217
+ });
5133
6218
  /**
5134
6219
  * Zod schema for OAuth configuration validation.
5135
6220
  *
5136
6221
  * @remarks
5137
- * All URLs must be valid and use HTTPS in production. The `jwkPrivate` field
5138
- * should contain the private key in JWK (JSON Web Key) format as a string.
6222
+ * All URLs must be valid and use HTTPS in production. For local development,
6223
+ * HTTP loopback URLs (localhost, 127.0.0.1, [::1]) are allowed.
6224
+ * The `jwkPrivate` field should contain the private key in JWK (JSON Web Key) format as a string.
5139
6225
  */
5140
6226
  const OAuthConfigSchema = zod.z.object({
5141
6227
  /**
5142
6228
  * URL to the OAuth client metadata JSON document.
5143
6229
  * This document describes your application to the authorization server.
5144
6230
  *
6231
+ * For local development, you can use `http://localhost/` as a loopback client.
6232
+ *
5145
6233
  * @see https://atproto.com/specs/oauth#client-metadata
5146
6234
  */
5147
- clientId: zod.z.string().url(),
6235
+ clientId: urlOrLoopback,
5148
6236
  /**
5149
6237
  * URL where users are redirected after authentication.
5150
6238
  * Must match one of the redirect URIs in your client metadata.
6239
+ *
6240
+ * For local development, you can use HTTP loopback URLs like
6241
+ * `http://127.0.0.1:3000/callback` or `http://localhost:3000/callback`.
5151
6242
  */
5152
- redirectUri: zod.z.string().url(),
6243
+ redirectUri: urlOrLoopback,
5153
6244
  /**
5154
6245
  * OAuth scopes to request, space-separated.
5155
6246
  *
@@ -5183,8 +6274,11 @@ const OAuthConfigSchema = zod.z.object({
5183
6274
  /**
5184
6275
  * URL to your public JWKS (JSON Web Key Set) endpoint.
5185
6276
  * Used by the authorization server to verify your client's signatures.
6277
+ *
6278
+ * For local development, you can serve JWKS from a loopback URL like
6279
+ * `http://127.0.0.1:3000/.well-known/jwks.json`.
5186
6280
  */
5187
- jwksUri: zod.z.string().url(),
6281
+ jwksUri: urlOrLoopback,
5188
6282
  /**
5189
6283
  * Private JWK (JSON Web Key) as a JSON string.
5190
6284
  * Used for signing DPoP proofs and client assertions.
@@ -5194,28 +6288,64 @@ const OAuthConfigSchema = zod.z.object({
5194
6288
  * Typically loaded from environment variables or a secrets manager.
5195
6289
  */
5196
6290
  jwkPrivate: zod.z.string(),
6291
+ /**
6292
+ * Enable development mode features (optional).
6293
+ *
6294
+ * When true, suppresses warnings about using HTTP loopback URLs.
6295
+ * Should be set to true for local development to reduce console noise.
6296
+ *
6297
+ * @default false
6298
+ *
6299
+ * @example
6300
+ * ```typescript
6301
+ * oauth: {
6302
+ * clientId: "http://localhost/",
6303
+ * redirectUri: "http://127.0.0.1:3000/callback",
6304
+ * // ... other config
6305
+ * developmentMode: true, // Suppress loopback warnings
6306
+ * }
6307
+ * ```
6308
+ */
6309
+ developmentMode: zod.z.boolean().optional(),
5197
6310
  });
5198
6311
  /**
5199
6312
  * Zod schema for server URL configuration.
5200
6313
  *
5201
6314
  * @remarks
5202
6315
  * At least one server (PDS or SDS) should be configured for the SDK to be useful.
6316
+ * For local development, HTTP loopback URLs are allowed.
5203
6317
  */
5204
6318
  const ServerConfigSchema = zod.z.object({
5205
6319
  /**
5206
6320
  * Personal Data Server URL - the user's own AT Protocol server.
5207
6321
  * This is the primary server for user data operations.
5208
6322
  *
5209
- * @example "https://bsky.social"
6323
+ * @example Production
6324
+ * ```typescript
6325
+ * pds: "https://bsky.social"
6326
+ * ```
6327
+ *
6328
+ * @example Local development
6329
+ * ```typescript
6330
+ * pds: "http://localhost:2583"
6331
+ * ```
5210
6332
  */
5211
- pds: zod.z.string().url().optional(),
6333
+ pds: urlOrLoopback.optional(),
5212
6334
  /**
5213
6335
  * Shared Data Server URL - for collaborative data storage.
5214
6336
  * Required for collaborator and organization operations.
5215
6337
  *
5216
- * @example "https://sds.hypercerts.org"
6338
+ * @example Production
6339
+ * ```typescript
6340
+ * sds: "https://sds.hypercerts.org"
6341
+ * ```
6342
+ *
6343
+ * @example Local development
6344
+ * ```typescript
6345
+ * sds: "http://127.0.0.1:2584"
6346
+ * ```
5217
6347
  */
5218
- sds: zod.z.string().url().optional(),
6348
+ sds: urlOrLoopback.optional(),
5219
6349
  });
5220
6350
  /**
5221
6351
  * Zod schema for timeout configuration.
@@ -5342,6 +6472,10 @@ class ATProtoSDK {
5342
6472
  };
5343
6473
  this.config = configWithDefaults;
5344
6474
  this.logger = config.logger;
6475
+ // Initialize lexicon registry with hypercert lexicons
6476
+ // Filter out undefined lexicons (some may not be exported from lexicon package yet)
6477
+ const validLexicons = HYPERCERT_LEXICONS.filter((lex) => lex !== undefined);
6478
+ this.lexiconRegistry = new LexiconRegistry(validLexicons);
5345
6479
  // Initialize OAuth client
5346
6480
  this.oauthClient = new OAuthClient(configWithDefaults);
5347
6481
  this.logger?.info("ATProto SDK initialized");
@@ -5625,45 +6759,1160 @@ class ATProtoSDK {
5625
6759
  }
5626
6760
  // Get repository DID (default to session DID)
5627
6761
  const repoDid = session.did || session.sub;
5628
- return new Repository(session, serverUrl, repoDid, isSDS, this.logger);
6762
+ return new Repository(session, serverUrl, repoDid, isSDS, this.logger, this.lexiconRegistry);
6763
+ }
6764
+ /**
6765
+ * Gets the LexiconRegistry instance for managing custom lexicons.
6766
+ *
6767
+ * The registry allows you to register custom lexicon schemas and validate
6768
+ * records against them. All registered lexicons will be automatically
6769
+ * validated during record creation operations.
6770
+ *
6771
+ * @returns The {@link LexiconRegistry} instance
6772
+ *
6773
+ * @example
6774
+ * ```typescript
6775
+ * // Register a custom lexicon
6776
+ * const registry = sdk.getLexiconRegistry();
6777
+ * registry.register({
6778
+ * lexicon: 1,
6779
+ * id: "org.myapp.customRecord",
6780
+ * defs: { ... }
6781
+ * });
6782
+ *
6783
+ * // Check if lexicon is registered
6784
+ * if (registry.isRegistered("org.myapp.customRecord")) {
6785
+ * console.log("Custom lexicon is available");
6786
+ * }
6787
+ * ```
6788
+ */
6789
+ getLexiconRegistry() {
6790
+ return this.lexiconRegistry;
6791
+ }
6792
+ /**
6793
+ * The configured PDS (Personal Data Server) URL.
6794
+ *
6795
+ * @returns The PDS URL if configured, otherwise `undefined`
6796
+ */
6797
+ get pdsUrl() {
6798
+ return this.config.servers?.pds;
6799
+ }
6800
+ /**
6801
+ * The configured SDS (Shared Data Server) URL.
6802
+ *
6803
+ * @returns The SDS URL if configured, otherwise `undefined`
6804
+ */
6805
+ get sdsUrl() {
6806
+ return this.config.servers?.sds;
6807
+ }
6808
+ }
6809
+ /**
6810
+ * Factory function to create an ATProto SDK instance.
6811
+ *
6812
+ * This is a convenience function equivalent to `new ATProtoSDK(config)`.
6813
+ *
6814
+ * @param config - SDK configuration
6815
+ * @returns A new {@link ATProtoSDK} instance
6816
+ *
6817
+ * @example
6818
+ * ```typescript
6819
+ * import { createATProtoSDK } from "@hypercerts-org/sdk";
6820
+ *
6821
+ * const sdk = createATProtoSDK({
6822
+ * oauth: { ... },
6823
+ * servers: { pds: "https://bsky.social" },
6824
+ * });
6825
+ * ```
6826
+ */
6827
+ function createATProtoSDK(config) {
6828
+ return new ATProtoSDK(config);
6829
+ }
6830
+
6831
+ /**
6832
+ * BaseOperations - Abstract base class for custom lexicon operations.
6833
+ *
6834
+ * This module provides a foundation for building domain-specific operation
6835
+ * classes that work with custom lexicons. It handles validation, record
6836
+ * creation, and provides utilities for working with AT Protocol records.
6837
+ *
6838
+ * @packageDocumentation
6839
+ */
6840
+ /**
6841
+ * Abstract base class for creating custom lexicon operation classes.
6842
+ *
6843
+ * Extend this class to build domain-specific operations for your custom
6844
+ * lexicons. The base class provides:
6845
+ *
6846
+ * - Automatic validation against registered lexicon schemas
6847
+ * - Helper methods for creating and updating records
6848
+ * - Utilities for building strongRefs and AT-URIs
6849
+ * - Error handling and logging support
6850
+ *
6851
+ * @typeParam TParams - Type of parameters accepted by the create() method
6852
+ * @typeParam TResult - Type of result returned by the create() method
6853
+ *
6854
+ * @remarks
6855
+ * This class is designed to be extended by developers creating custom
6856
+ * operation classes for their own lexicons. It follows the same patterns
6857
+ * as the built-in hypercert operations.
6858
+ *
6859
+ * @example Basic usage
6860
+ * ```typescript
6861
+ * import { BaseOperations } from "@hypercerts-org/sdk-core";
6862
+ *
6863
+ * interface EvaluationParams {
6864
+ * subjectUri: string;
6865
+ * subjectCid: string;
6866
+ * score: number;
6867
+ * methodology?: string;
6868
+ * }
6869
+ *
6870
+ * interface EvaluationResult {
6871
+ * uri: string;
6872
+ * cid: string;
6873
+ * record: MyEvaluation;
6874
+ * }
6875
+ *
6876
+ * class EvaluationOperations extends BaseOperations<EvaluationParams, EvaluationResult> {
6877
+ * async create(params: EvaluationParams): Promise<EvaluationResult> {
6878
+ * const record = {
6879
+ * $type: "org.myapp.evaluation",
6880
+ * subject: this.createStrongRef(params.subjectUri, params.subjectCid),
6881
+ * score: params.score,
6882
+ * methodology: params.methodology,
6883
+ * createdAt: new Date().toISOString(),
6884
+ * };
6885
+ *
6886
+ * const { uri, cid } = await this.validateAndCreate("org.myapp.evaluation", record);
6887
+ * return { uri, cid, record };
6888
+ * }
6889
+ * }
6890
+ * ```
6891
+ *
6892
+ * @example With validation and error handling
6893
+ * ```typescript
6894
+ * class ProjectOperations extends BaseOperations<CreateProjectParams, ProjectResult> {
6895
+ * async create(params: CreateProjectParams): Promise<ProjectResult> {
6896
+ * // Validate input parameters
6897
+ * if (!params.title || params.title.trim().length === 0) {
6898
+ * throw new ValidationError("Project title cannot be empty");
6899
+ * }
6900
+ *
6901
+ * const record = {
6902
+ * $type: "org.myapp.project",
6903
+ * title: params.title,
6904
+ * description: params.description,
6905
+ * createdAt: new Date().toISOString(),
6906
+ * };
6907
+ *
6908
+ * try {
6909
+ * const { uri, cid } = await this.validateAndCreate("org.myapp.project", record);
6910
+ * this.logger?.info(`Created project: ${uri}`);
6911
+ * return { uri, cid, record };
6912
+ * } catch (error) {
6913
+ * this.logger?.error(`Failed to create project: ${error}`);
6914
+ * throw error;
6915
+ * }
6916
+ * }
6917
+ * }
6918
+ * ```
6919
+ */
6920
+ class BaseOperations {
6921
+ /**
6922
+ * Creates a new BaseOperations instance.
6923
+ *
6924
+ * @param agent - AT Protocol Agent for making API calls
6925
+ * @param repoDid - DID of the repository to operate on
6926
+ * @param lexiconRegistry - Registry for validating records against lexicon schemas
6927
+ * @param logger - Optional logger for debugging and monitoring
6928
+ *
6929
+ * @internal
6930
+ */
6931
+ constructor(agent, repoDid, lexiconRegistry, logger) {
6932
+ this.agent = agent;
6933
+ this.repoDid = repoDid;
6934
+ this.lexiconRegistry = lexiconRegistry;
6935
+ this.logger = logger;
6936
+ }
6937
+ /**
6938
+ * Validates a record against its lexicon schema and creates it in the repository.
6939
+ *
6940
+ * This method performs the following steps:
6941
+ * 1. Validates the record against the registered lexicon schema
6942
+ * 2. Throws ValidationError if validation fails
6943
+ * 3. Creates the record using the AT Protocol Agent
6944
+ * 4. Returns the created record's URI and CID
6945
+ *
6946
+ * @param collection - NSID of the collection (e.g., "org.myapp.customRecord")
6947
+ * @param record - Record data conforming to the collection's lexicon schema
6948
+ * @param rkey - Optional record key. If not provided, a TID is auto-generated
6949
+ * @returns Promise resolving to the created record's URI and CID
6950
+ * @throws {@link ValidationError} if the record doesn't conform to the lexicon schema
6951
+ * @throws {@link NetworkError} if the API request fails
6952
+ *
6953
+ * @example
6954
+ * ```typescript
6955
+ * const record = {
6956
+ * $type: "org.myapp.evaluation",
6957
+ * subject: { uri: "at://...", cid: "bafyrei..." },
6958
+ * score: 85,
6959
+ * createdAt: new Date().toISOString(),
6960
+ * };
6961
+ *
6962
+ * const { uri, cid } = await this.validateAndCreate("org.myapp.evaluation", record);
6963
+ * ```
6964
+ */
6965
+ async validateAndCreate(collection, record, rkey) {
6966
+ // Validate record against registered lexicon
6967
+ if (this.lexiconRegistry.isRegistered(collection)) {
6968
+ const validation = this.lexiconRegistry.validate(collection, record);
6969
+ if (!validation.valid) {
6970
+ throw new ValidationError(`Invalid ${collection}: ${validation.error}`);
6971
+ }
6972
+ }
6973
+ try {
6974
+ const result = await this.agent.com.atproto.repo.createRecord({
6975
+ repo: this.repoDid,
6976
+ collection,
6977
+ record: record,
6978
+ rkey,
6979
+ });
6980
+ if (!result.success) {
6981
+ throw new NetworkError("Failed to create record");
6982
+ }
6983
+ return { uri: result.data.uri, cid: result.data.cid };
6984
+ }
6985
+ catch (error) {
6986
+ if (error instanceof ValidationError || error instanceof NetworkError) {
6987
+ throw error;
6988
+ }
6989
+ throw new NetworkError(`Failed to create ${collection}: ${error instanceof Error ? error.message : "Unknown error"}`, error);
6990
+ }
6991
+ }
6992
+ /**
6993
+ * Validates a record against its lexicon schema and updates it in the repository.
6994
+ *
6995
+ * This method performs the following steps:
6996
+ * 1. Validates the record against the registered lexicon schema
6997
+ * 2. Throws ValidationError if validation fails
6998
+ * 3. Updates the record using the AT Protocol Agent
6999
+ * 4. Returns the updated record's URI and new CID
7000
+ *
7001
+ * @param collection - NSID of the collection
7002
+ * @param rkey - Record key (the last segment of the AT-URI)
7003
+ * @param record - New record data (completely replaces existing record)
7004
+ * @returns Promise resolving to the updated record's URI and new CID
7005
+ * @throws {@link ValidationError} if the record doesn't conform to the lexicon schema
7006
+ * @throws {@link NetworkError} if the API request fails
7007
+ *
7008
+ * @remarks
7009
+ * This is a full replacement operation, not a partial update.
7010
+ *
7011
+ * @example
7012
+ * ```typescript
7013
+ * const updatedRecord = {
7014
+ * $type: "org.myapp.evaluation",
7015
+ * subject: { uri: "at://...", cid: "bafyrei..." },
7016
+ * score: 90, // Updated score
7017
+ * createdAt: existingRecord.createdAt,
7018
+ * };
7019
+ *
7020
+ * const { uri, cid } = await this.validateAndUpdate(
7021
+ * "org.myapp.evaluation",
7022
+ * "abc123",
7023
+ * updatedRecord
7024
+ * );
7025
+ * ```
7026
+ */
7027
+ async validateAndUpdate(collection, rkey, record) {
7028
+ // Validate record against registered lexicon
7029
+ if (this.lexiconRegistry.isRegistered(collection)) {
7030
+ const validation = this.lexiconRegistry.validate(collection, record);
7031
+ if (!validation.valid) {
7032
+ throw new ValidationError(`Invalid ${collection}: ${validation.error}`);
7033
+ }
7034
+ }
7035
+ try {
7036
+ const result = await this.agent.com.atproto.repo.putRecord({
7037
+ repo: this.repoDid,
7038
+ collection,
7039
+ rkey,
7040
+ record: record,
7041
+ });
7042
+ if (!result.success) {
7043
+ throw new NetworkError("Failed to update record");
7044
+ }
7045
+ return { uri: result.data.uri, cid: result.data.cid };
7046
+ }
7047
+ catch (error) {
7048
+ if (error instanceof ValidationError || error instanceof NetworkError) {
7049
+ throw error;
7050
+ }
7051
+ throw new NetworkError(`Failed to update ${collection}: ${error instanceof Error ? error.message : "Unknown error"}`, error);
7052
+ }
7053
+ }
7054
+ /**
7055
+ * Creates a strongRef object from a URI and CID.
7056
+ *
7057
+ * StrongRefs are used in AT Protocol to reference specific versions
7058
+ * of records. They ensure that references point to an exact record
7059
+ * version, not just the latest version.
7060
+ *
7061
+ * @param uri - AT-URI of the record (e.g., "at://did:plc:abc/collection/rkey")
7062
+ * @param cid - Content Identifier (CID) of the record
7063
+ * @returns StrongRef object with uri and cid properties
7064
+ *
7065
+ * @example
7066
+ * ```typescript
7067
+ * const hypercertRef = this.createStrongRef(
7068
+ * "at://did:plc:abc123/org.hypercerts.claim.activity/xyz789",
7069
+ * "bafyreiabc123..."
7070
+ * );
7071
+ *
7072
+ * const evaluation = {
7073
+ * $type: "org.myapp.evaluation",
7074
+ * subject: hypercertRef, // Reference to specific hypercert version
7075
+ * score: 85,
7076
+ * createdAt: new Date().toISOString(),
7077
+ * };
7078
+ * ```
7079
+ */
7080
+ createStrongRef(uri, cid) {
7081
+ return { uri, cid };
7082
+ }
7083
+ /**
7084
+ * Creates a strongRef from a CreateResult or UpdateResult.
7085
+ *
7086
+ * This is a convenience method for creating strongRefs from the
7087
+ * results of create or update operations.
7088
+ *
7089
+ * @param result - Result from a create or update operation
7090
+ * @returns StrongRef object with uri and cid properties
7091
+ *
7092
+ * @example
7093
+ * ```typescript
7094
+ * // Create a project
7095
+ * const projectResult = await this.validateAndCreate("org.myapp.project", projectRecord);
7096
+ *
7097
+ * // Create a task that references the project
7098
+ * const taskRecord = {
7099
+ * $type: "org.myapp.task",
7100
+ * project: this.createStrongRefFromResult(projectResult),
7101
+ * title: "Implement feature",
7102
+ * createdAt: new Date().toISOString(),
7103
+ * };
7104
+ * ```
7105
+ */
7106
+ createStrongRefFromResult(result) {
7107
+ return { uri: result.uri, cid: result.cid };
7108
+ }
7109
+ /**
7110
+ * Parses an AT-URI to extract its components.
7111
+ *
7112
+ * AT-URIs follow the format: `at://{did}/{collection}/{rkey}`
7113
+ *
7114
+ * @param uri - AT-URI to parse
7115
+ * @returns Object containing did, collection, and rkey
7116
+ * @throws Error if the URI format is invalid
7117
+ *
7118
+ * @example
7119
+ * ```typescript
7120
+ * const { did, collection, rkey } = this.parseAtUri(
7121
+ * "at://did:plc:abc123/org.hypercerts.claim.activity/xyz789"
7122
+ * );
7123
+ * // did: "did:plc:abc123"
7124
+ * // collection: "org.hypercerts.claim.activity"
7125
+ * // rkey: "xyz789"
7126
+ * ```
7127
+ */
7128
+ parseAtUri(uri) {
7129
+ if (!uri.startsWith("at://")) {
7130
+ throw new Error(`Invalid AT-URI format: ${uri}`);
7131
+ }
7132
+ const parts = uri.slice(5).split("/"); // Remove "at://" and split
7133
+ if (parts.length !== 3) {
7134
+ throw new Error(`Invalid AT-URI format: ${uri}`);
7135
+ }
7136
+ return {
7137
+ did: parts[0],
7138
+ collection: parts[1],
7139
+ rkey: parts[2],
7140
+ };
7141
+ }
7142
+ /**
7143
+ * Builds an AT-URI from its components.
7144
+ *
7145
+ * @param did - DID of the repository
7146
+ * @param collection - NSID of the collection
7147
+ * @param rkey - Record key (typically a TID)
7148
+ * @returns Complete AT-URI string
7149
+ *
7150
+ * @example
7151
+ * ```typescript
7152
+ * const uri = this.buildAtUri(
7153
+ * "did:plc:abc123",
7154
+ * "org.myapp.evaluation",
7155
+ * "xyz789"
7156
+ * );
7157
+ * // Returns: "at://did:plc:abc123/org.myapp.evaluation/xyz789"
7158
+ * ```
7159
+ */
7160
+ buildAtUri(did, collection, rkey) {
7161
+ return `at://${did}/${collection}/${rkey}`;
7162
+ }
7163
+ }
7164
+
7165
+ /**
7166
+ * Lexicon Development Utilities - AT-URI and StrongRef Helpers
7167
+ *
7168
+ * This module provides utilities for working with AT Protocol URIs and strongRefs
7169
+ * when building custom lexicons. These tools help developers create type-safe
7170
+ * references between records.
7171
+ *
7172
+ * @packageDocumentation
7173
+ */
7174
+ /**
7175
+ * Parse an AT-URI into its component parts.
7176
+ *
7177
+ * Extracts the DID, collection NSID, and record key from an AT-URI string.
7178
+ * AT-URIs follow the format: `at://{did}/{collection}/{rkey}`
7179
+ *
7180
+ * @param uri - The AT-URI to parse
7181
+ * @returns The components of the URI
7182
+ * @throws {Error} If the URI format is invalid
7183
+ *
7184
+ * @example
7185
+ * ```typescript
7186
+ * const components = parseAtUri("at://did:plc:abc123/org.hypercerts.claim.activity/3km2vj4kfqp2a");
7187
+ * console.log(components);
7188
+ * // {
7189
+ * // did: "did:plc:abc123",
7190
+ * // collection: "org.hypercerts.claim.activity",
7191
+ * // rkey: "3km2vj4kfqp2a"
7192
+ * // }
7193
+ * ```
7194
+ */
7195
+ function parseAtUri(uri) {
7196
+ if (!uri.startsWith("at://")) {
7197
+ throw new Error(`Invalid AT-URI format: must start with "at://", got "${uri}"`);
7198
+ }
7199
+ const withoutProtocol = uri.slice(5); // Remove "at://"
7200
+ const parts = withoutProtocol.split("/");
7201
+ if (parts.length !== 3) {
7202
+ throw new Error(`Invalid AT-URI format: expected "at://{did}/{collection}/{rkey}", got "${uri}"`);
7203
+ }
7204
+ const [did, collection, rkey] = parts;
7205
+ if (!did || !collection || !rkey) {
7206
+ throw new Error(`Invalid AT-URI format: all components must be non-empty, got "${uri}"`);
7207
+ }
7208
+ return { did, collection, rkey };
7209
+ }
7210
+ /**
7211
+ * Build an AT-URI from its component parts.
7212
+ *
7213
+ * Constructs a valid AT-URI string from a DID, collection NSID, and record key.
7214
+ * The resulting URI follows the format: `at://{did}/{collection}/{rkey}`
7215
+ *
7216
+ * @param did - The repository owner's DID
7217
+ * @param collection - The collection NSID (lexicon identifier)
7218
+ * @param rkey - The record key (TID or custom string)
7219
+ * @returns The complete AT-URI
7220
+ *
7221
+ * @example
7222
+ * ```typescript
7223
+ * const uri = buildAtUri(
7224
+ * "did:plc:abc123",
7225
+ * "org.hypercerts.claim.activity",
7226
+ * "3km2vj4kfqp2a"
7227
+ * );
7228
+ * console.log(uri); // "at://did:plc:abc123/org.hypercerts.claim.activity/3km2vj4kfqp2a"
7229
+ * ```
7230
+ */
7231
+ function buildAtUri(did, collection, rkey) {
7232
+ if (!did || !collection || !rkey) {
7233
+ throw new Error("All AT-URI components (did, collection, rkey) must be non-empty");
7234
+ }
7235
+ return `at://${did}/${collection}/${rkey}`;
7236
+ }
7237
+ /**
7238
+ * Extract the record key (TID or custom key) from an AT-URI.
7239
+ *
7240
+ * Returns the last component of the AT-URI, which is the record key.
7241
+ * This is equivalent to `parseAtUri(uri).rkey` but more efficient.
7242
+ *
7243
+ * @param uri - The AT-URI to extract from
7244
+ * @returns The record key (TID or custom string)
7245
+ * @throws {Error} If the URI format is invalid
7246
+ *
7247
+ * @example
7248
+ * ```typescript
7249
+ * const rkey = extractRkeyFromUri("at://did:plc:abc123/org.hypercerts.claim.activity/3km2vj4kfqp2a");
7250
+ * console.log(rkey); // "3km2vj4kfqp2a"
7251
+ * ```
7252
+ */
7253
+ function extractRkeyFromUri(uri) {
7254
+ const { rkey } = parseAtUri(uri);
7255
+ return rkey;
7256
+ }
7257
+ /**
7258
+ * Check if a string is a valid AT-URI format.
7259
+ *
7260
+ * Validates that the string follows the AT-URI format without throwing errors.
7261
+ * This is useful for input validation before parsing.
7262
+ *
7263
+ * @param uri - The string to validate
7264
+ * @returns True if the string is a valid AT-URI, false otherwise
7265
+ *
7266
+ * @example
7267
+ * ```typescript
7268
+ * if (isValidAtUri(userInput)) {
7269
+ * const components = parseAtUri(userInput);
7270
+ * // ... use components
7271
+ * } else {
7272
+ * console.error("Invalid AT-URI");
7273
+ * }
7274
+ * ```
7275
+ */
7276
+ function isValidAtUri(uri) {
7277
+ try {
7278
+ parseAtUri(uri);
7279
+ return true;
7280
+ }
7281
+ catch {
7282
+ return false;
5629
7283
  }
5630
- /**
5631
- * The configured PDS (Personal Data Server) URL.
5632
- *
5633
- * @returns The PDS URL if configured, otherwise `undefined`
5634
- */
5635
- get pdsUrl() {
5636
- return this.config.servers?.pds;
7284
+ }
7285
+ /**
7286
+ * Create a strongRef from a URI and CID.
7287
+ *
7288
+ * StrongRefs are the canonical way to reference specific versions of records
7289
+ * in AT Protocol. They combine an AT-URI (which identifies the record) with
7290
+ * a CID (which identifies the specific version).
7291
+ *
7292
+ * @param uri - The AT-URI of the record
7293
+ * @param cid - The CID (Content Identifier) of the record version
7294
+ * @returns A strongRef object
7295
+ *
7296
+ * @example
7297
+ * ```typescript
7298
+ * const ref = createStrongRef(
7299
+ * "at://did:plc:abc123/org.hypercerts.claim.activity/3km2vj4kfqp2a",
7300
+ * "bafyreiabc123..."
7301
+ * );
7302
+ * console.log(ref);
7303
+ * // {
7304
+ * // uri: "at://did:plc:abc123/org.hypercerts.claim.activity/3km2vj4kfqp2a",
7305
+ * // cid: "bafyreiabc123..."
7306
+ * // }
7307
+ * ```
7308
+ */
7309
+ function createStrongRef(uri, cid) {
7310
+ if (!uri || !cid) {
7311
+ throw new Error("Both uri and cid are required to create a strongRef");
5637
7312
  }
5638
- /**
5639
- * The configured SDS (Shared Data Server) URL.
5640
- *
5641
- * @returns The SDS URL if configured, otherwise `undefined`
5642
- */
5643
- get sdsUrl() {
5644
- return this.config.servers?.sds;
7313
+ return { uri, cid };
7314
+ }
7315
+ /**
7316
+ * Create a strongRef from a CreateResult or UpdateResult.
7317
+ *
7318
+ * This is a convenience function that extracts the URI and CID from
7319
+ * the result of a record creation or update operation.
7320
+ *
7321
+ * @param result - The result from creating or updating a record
7322
+ * @returns A strongRef object
7323
+ *
7324
+ * @example
7325
+ * ```typescript
7326
+ * const hypercert = await repo.hypercerts.create({
7327
+ * title: "Climate Research",
7328
+ * // ... other params
7329
+ * });
7330
+ *
7331
+ * const ref = createStrongRefFromResult(hypercert);
7332
+ * // Now use ref in another record to reference this hypercert
7333
+ * ```
7334
+ */
7335
+ function createStrongRefFromResult(result) {
7336
+ return createStrongRef(result.uri, result.cid);
7337
+ }
7338
+ /**
7339
+ * Validate that an object is a valid strongRef.
7340
+ *
7341
+ * Checks that the object has the required `uri` and `cid` properties
7342
+ * and that they are non-empty strings.
7343
+ *
7344
+ * @param ref - The object to validate
7345
+ * @returns True if the object is a valid strongRef, false otherwise
7346
+ *
7347
+ * @example
7348
+ * ```typescript
7349
+ * const maybeRef = { uri: "at://...", cid: "bafyrei..." };
7350
+ * if (validateStrongRef(maybeRef)) {
7351
+ * // Safe to use as strongRef
7352
+ * record.subject = maybeRef;
7353
+ * }
7354
+ * ```
7355
+ */
7356
+ function validateStrongRef(ref) {
7357
+ if (!ref || typeof ref !== "object") {
7358
+ return false;
5645
7359
  }
7360
+ const obj = ref;
7361
+ return typeof obj.uri === "string" && obj.uri.length > 0 && typeof obj.cid === "string" && obj.cid.length > 0;
5646
7362
  }
5647
7363
  /**
5648
- * Factory function to create an ATProto SDK instance.
7364
+ * Type guard to check if a value is a strongRef.
5649
7365
  *
5650
- * This is a convenience function equivalent to `new ATProtoSDK(config)`.
7366
+ * This is an alias for `validateStrongRef` that provides better semantics
7367
+ * for type narrowing in TypeScript.
5651
7368
  *
5652
- * @param config - SDK configuration
5653
- * @returns A new {@link ATProtoSDK} instance
7369
+ * @param value - The value to check
7370
+ * @returns True if the value is a strongRef, false otherwise
5654
7371
  *
5655
7372
  * @example
5656
7373
  * ```typescript
5657
- * import { createATProtoSDK } from "@hypercerts-org/sdk";
7374
+ * function processReference(ref: unknown) {
7375
+ * if (isStrongRef(ref)) {
7376
+ * // TypeScript knows ref is StrongRef here
7377
+ * console.log(ref.uri);
7378
+ * }
7379
+ * }
7380
+ * ```
7381
+ */
7382
+ function isStrongRef(value) {
7383
+ return validateStrongRef(value);
7384
+ }
7385
+
7386
+ /**
7387
+ * Lexicon Builder Utilities
5658
7388
  *
5659
- * const sdk = createATProtoSDK({
5660
- * oauth: { ... },
5661
- * servers: { pds: "https://bsky.social" },
7389
+ * This module provides utilities for constructing lexicon JSON schemas programmatically.
7390
+ * These builders help developers create valid AT Protocol lexicons with proper structure
7391
+ * and type-safe field definitions.
7392
+ *
7393
+ * @packageDocumentation
7394
+ */
7395
+ /**
7396
+ * Create a string field definition.
7397
+ *
7398
+ * @param options - String field options
7399
+ * @returns A lexicon string field definition
7400
+ *
7401
+ * @example
7402
+ * ```typescript
7403
+ * const titleField = createStringField({
7404
+ * description: "Title of the item",
7405
+ * minLength: 1,
7406
+ * maxLength: 200,
5662
7407
  * });
5663
7408
  * ```
5664
7409
  */
5665
- function createATProtoSDK(config) {
5666
- return new ATProtoSDK(config);
7410
+ function createStringField(options = {}) {
7411
+ return { type: "string", ...options };
7412
+ }
7413
+ /**
7414
+ * Create an integer field definition.
7415
+ *
7416
+ * @param options - Integer field options
7417
+ * @returns A lexicon integer field definition
7418
+ *
7419
+ * @example
7420
+ * ```typescript
7421
+ * const scoreField = createIntegerField({
7422
+ * description: "Score from 0 to 100",
7423
+ * minimum: 0,
7424
+ * maximum: 100,
7425
+ * });
7426
+ * ```
7427
+ */
7428
+ function createIntegerField(options = {}) {
7429
+ return { type: "integer", ...options };
7430
+ }
7431
+ /**
7432
+ * Create a number field definition.
7433
+ *
7434
+ * @param options - Number field options
7435
+ * @returns A lexicon number field definition
7436
+ *
7437
+ * @example
7438
+ * ```typescript
7439
+ * const weightField = createNumberField({
7440
+ * description: "Weight as decimal",
7441
+ * minimum: 0,
7442
+ * maximum: 1,
7443
+ * });
7444
+ * ```
7445
+ */
7446
+ function createNumberField(options = {}) {
7447
+ return { type: "number", ...options };
7448
+ }
7449
+ /**
7450
+ * Create a boolean field definition.
7451
+ *
7452
+ * @param options - Boolean field options
7453
+ * @returns A lexicon boolean field definition
7454
+ *
7455
+ * @example
7456
+ * ```typescript
7457
+ * const verifiedField = createBooleanField({
7458
+ * description: "Whether the item is verified",
7459
+ * default: false,
7460
+ * });
7461
+ * ```
7462
+ */
7463
+ function createBooleanField(options = {}) {
7464
+ return { type: "boolean", ...options };
7465
+ }
7466
+ /**
7467
+ * Create a strongRef field definition.
7468
+ *
7469
+ * StrongRefs are the standard way to reference other records in AT Protocol.
7470
+ * They contain both the AT-URI and CID of the referenced record.
7471
+ *
7472
+ * @param options - Reference field options
7473
+ * @returns A lexicon reference field definition
7474
+ *
7475
+ * @example
7476
+ * ```typescript
7477
+ * const subjectField = createStrongRefField({
7478
+ * description: "The hypercert being evaluated",
7479
+ * });
7480
+ * ```
7481
+ */
7482
+ function createStrongRefField(options = {}) {
7483
+ return {
7484
+ type: "ref",
7485
+ ref: options.ref || "com.atproto.repo.strongRef",
7486
+ description: options.description,
7487
+ };
7488
+ }
7489
+ /**
7490
+ * Create an array field definition.
7491
+ *
7492
+ * @param itemType - The type of items in the array
7493
+ * @param options - Array field options
7494
+ * @returns A lexicon array field definition
7495
+ *
7496
+ * @example
7497
+ * ```typescript
7498
+ * const tagsField = createArrayField(
7499
+ * createStringField({ maxLength: 50 }),
7500
+ * {
7501
+ * description: "List of tags",
7502
+ * minLength: 1,
7503
+ * maxLength: 10,
7504
+ * }
7505
+ * );
7506
+ * ```
7507
+ */
7508
+ function createArrayField(itemType, options = {}) {
7509
+ return {
7510
+ type: "array",
7511
+ items: itemType,
7512
+ ...options,
7513
+ };
7514
+ }
7515
+ /**
7516
+ * Create an object field definition.
7517
+ *
7518
+ * @param options - Object field options
7519
+ * @returns A lexicon object field definition
7520
+ *
7521
+ * @example
7522
+ * ```typescript
7523
+ * const metadataField = createObjectField({
7524
+ * description: "Additional metadata",
7525
+ * properties: {
7526
+ * author: createStringField(),
7527
+ * version: createIntegerField(),
7528
+ * },
7529
+ * required: ["author"],
7530
+ * });
7531
+ * ```
7532
+ */
7533
+ function createObjectField(options = {}) {
7534
+ return { type: "object", ...options };
7535
+ }
7536
+ /**
7537
+ * Create a blob field definition.
7538
+ *
7539
+ * @param options - Blob field options
7540
+ * @returns A lexicon blob field definition
7541
+ *
7542
+ * @example
7543
+ * ```typescript
7544
+ * const imageField = createBlobField({
7545
+ * description: "Profile image",
7546
+ * accept: ["image/png", "image/jpeg"],
7547
+ * maxSize: 1000000, // 1MB
7548
+ * });
7549
+ * ```
7550
+ */
7551
+ function createBlobField(options = {}) {
7552
+ return { type: "blob", ...options };
7553
+ }
7554
+ /**
7555
+ * Create a datetime string field.
7556
+ *
7557
+ * This is a convenience function for creating string fields with datetime format.
7558
+ *
7559
+ * @param options - String field options
7560
+ * @returns A lexicon string field with datetime format
7561
+ *
7562
+ * @example
7563
+ * ```typescript
7564
+ * const createdAtField = createDatetimeField({
7565
+ * description: "When the record was created",
7566
+ * });
7567
+ * ```
7568
+ */
7569
+ function createDatetimeField(options = {}) {
7570
+ return {
7571
+ type: "string",
7572
+ format: "datetime",
7573
+ ...options,
7574
+ };
7575
+ }
7576
+ /**
7577
+ * Create a record definition.
7578
+ *
7579
+ * This defines the structure of records in your lexicon.
7580
+ *
7581
+ * @param properties - The record's properties (fields)
7582
+ * @param required - Array of required field names
7583
+ * @param keyType - The type of record key ("tid" for server-generated, "any" for custom)
7584
+ * @returns A lexicon record definition
7585
+ *
7586
+ * @example
7587
+ * ```typescript
7588
+ * const recordDef = createRecordDef(
7589
+ * {
7590
+ * $type: createStringField({ const: "org.myapp.evaluation" }),
7591
+ * subject: createStrongRefField({ description: "The evaluated item" }),
7592
+ * score: createIntegerField({ minimum: 0, maximum: 100 }),
7593
+ * createdAt: createDatetimeField(),
7594
+ * },
7595
+ * ["$type", "subject", "score", "createdAt"],
7596
+ * "tid"
7597
+ * );
7598
+ * ```
7599
+ */
7600
+ function createRecordDef(properties, required, keyType = "tid") {
7601
+ return {
7602
+ type: "record",
7603
+ key: keyType,
7604
+ record: {
7605
+ type: "object",
7606
+ required,
7607
+ properties,
7608
+ },
7609
+ };
7610
+ }
7611
+ /**
7612
+ * Create a complete lexicon document.
7613
+ *
7614
+ * This creates a full lexicon JSON structure that can be registered with the SDK.
7615
+ *
7616
+ * @param nsid - The NSID (Namespaced Identifier) for this lexicon
7617
+ * @param properties - The record's properties (fields)
7618
+ * @param required - Array of required field names
7619
+ * @param keyType - The type of record key ("tid" for server-generated, "any" for custom)
7620
+ * @returns A complete lexicon document
7621
+ *
7622
+ * @example
7623
+ * ```typescript
7624
+ * const lexicon = createLexiconDoc(
7625
+ * "org.myapp.evaluation",
7626
+ * {
7627
+ * $type: createStringField({ const: "org.myapp.evaluation" }),
7628
+ * subject: createStrongRefField({ description: "The evaluated item" }),
7629
+ * score: createIntegerField({ minimum: 0, maximum: 100 }),
7630
+ * methodology: createStringField({ maxLength: 500 }),
7631
+ * createdAt: createDatetimeField(),
7632
+ * },
7633
+ * ["$type", "subject", "score", "createdAt"],
7634
+ * "tid"
7635
+ * );
7636
+ *
7637
+ * // Register with SDK
7638
+ * sdk.getLexiconRegistry().registerFromJSON(lexicon);
7639
+ * ```
7640
+ */
7641
+ function createLexiconDoc(nsid, properties, required, keyType = "tid") {
7642
+ return {
7643
+ lexicon: 1,
7644
+ id: nsid,
7645
+ defs: {
7646
+ main: createRecordDef(properties, required, keyType),
7647
+ },
7648
+ };
7649
+ }
7650
+ /**
7651
+ * Validate a lexicon document structure.
7652
+ *
7653
+ * Performs basic validation to ensure the lexicon follows AT Protocol conventions.
7654
+ * This does NOT perform full JSON schema validation.
7655
+ *
7656
+ * @param lexicon - The lexicon document to validate
7657
+ * @returns True if valid, false otherwise
7658
+ *
7659
+ * @example
7660
+ * ```typescript
7661
+ * const lexicon = createLexiconDoc(...);
7662
+ * if (validateLexiconStructure(lexicon)) {
7663
+ * sdk.getLexiconRegistry().registerFromJSON(lexicon);
7664
+ * } else {
7665
+ * console.error("Invalid lexicon structure");
7666
+ * }
7667
+ * ```
7668
+ */
7669
+ function validateLexiconStructure(lexicon) {
7670
+ if (!lexicon || typeof lexicon !== "object") {
7671
+ return false;
7672
+ }
7673
+ const doc = lexicon;
7674
+ // Check required top-level fields
7675
+ if (doc.lexicon !== 1)
7676
+ return false;
7677
+ if (typeof doc.id !== "string" || !doc.id)
7678
+ return false;
7679
+ if (!doc.defs || typeof doc.defs !== "object")
7680
+ return false;
7681
+ const defs = doc.defs;
7682
+ if (!defs.main || typeof defs.main !== "object")
7683
+ return false;
7684
+ const main = defs.main;
7685
+ if (main.type !== "record")
7686
+ return false;
7687
+ if (!main.record || typeof main.record !== "object")
7688
+ return false;
7689
+ const record = main.record;
7690
+ if (record.type !== "object")
7691
+ return false;
7692
+ if (!Array.isArray(record.required))
7693
+ return false;
7694
+ if (!record.properties || typeof record.properties !== "object")
7695
+ return false;
7696
+ return true;
7697
+ }
7698
+
7699
+ /**
7700
+ * Sidecar Pattern Utilities
7701
+ *
7702
+ * This module provides utilities for implementing the AT Protocol "sidecar pattern"
7703
+ * where additional records are created that reference a main record via StrongRef.
7704
+ *
7705
+ * ## The Sidecar Pattern
7706
+ *
7707
+ * In AT Protocol, the sidecar pattern uses **unidirectional references**:
7708
+ * - Sidecar records contain a StrongRef (uri + cid) pointing to the main record
7709
+ * - Main records do NOT maintain back-references to sidecars
7710
+ * - Sidecars are discovered by querying records that reference the main record
7711
+ * - Optionally, sidecars can use the same rkey as the main record (in different collections)
7712
+ *
7713
+ * ## Example Use Cases
7714
+ *
7715
+ * - Evaluations that reference hypercerts
7716
+ * - Comments that reference posts
7717
+ * - Metadata records that reference primary entities
7718
+ *
7719
+ * @see https://atproto.com/specs/record-key
7720
+ * @see https://atproto.com/specs/data-model
7721
+ *
7722
+ * @packageDocumentation
7723
+ */
7724
+ /**
7725
+ * Create a sidecar record that references an existing record.
7726
+ *
7727
+ * This is a low-level utility that creates a single sidecar record. The sidecar
7728
+ * record should include a strongRef field that points to the main record.
7729
+ *
7730
+ * @param repo - The repository instance
7731
+ * @param collection - The collection NSID for the sidecar
7732
+ * @param record - The sidecar record data (should include a strongRef to the main record)
7733
+ * @param options - Optional creation options
7734
+ * @returns The created sidecar record
7735
+ *
7736
+ * @example
7737
+ * ```typescript
7738
+ * const hypercert = await repo.hypercerts.create({...});
7739
+ *
7740
+ * const evaluation = await createSidecarRecord(
7741
+ * repo,
7742
+ * "org.myapp.evaluation",
7743
+ * {
7744
+ * $type: "org.myapp.evaluation",
7745
+ * subject: { uri: hypercert.hypercertUri, cid: hypercert.hypercertCid },
7746
+ * score: 85,
7747
+ * createdAt: new Date().toISOString(),
7748
+ * }
7749
+ * );
7750
+ * ```
7751
+ */
7752
+ async function createSidecarRecord(repo, collection, record, options = {}) {
7753
+ return await repo.records.create({
7754
+ collection,
7755
+ record,
7756
+ rkey: options.rkey,
7757
+ });
7758
+ }
7759
+ /**
7760
+ * Attach a sidecar record to an existing main record.
7761
+ *
7762
+ * This creates a new record that references an existing record via strongRef.
7763
+ * It's a higher-level convenience function that wraps `createSidecarRecord`.
7764
+ *
7765
+ * @param repo - The repository instance
7766
+ * @param params - Parameters including the main record reference and sidecar definition
7767
+ * @returns Both the main record reference and the created sidecar
7768
+ *
7769
+ * @example
7770
+ * ```typescript
7771
+ * const hypercert = await repo.hypercerts.create({...});
7772
+ *
7773
+ * const result = await attachSidecar(repo, {
7774
+ * mainRecord: {
7775
+ * uri: hypercert.hypercertUri,
7776
+ * cid: hypercert.hypercertCid,
7777
+ * },
7778
+ * sidecar: {
7779
+ * collection: "org.myapp.evaluation",
7780
+ * record: {
7781
+ * $type: "org.myapp.evaluation",
7782
+ * subject: { uri: hypercert.hypercertUri, cid: hypercert.hypercertCid },
7783
+ * score: 85,
7784
+ * createdAt: new Date().toISOString(),
7785
+ * },
7786
+ * },
7787
+ * });
7788
+ * ```
7789
+ */
7790
+ async function attachSidecar(repo, params) {
7791
+ const sidecarRecord = await createSidecarRecord(repo, params.sidecar.collection, params.sidecar.record, {
7792
+ rkey: params.sidecar.rkey,
7793
+ });
7794
+ return {
7795
+ mainRecord: params.mainRecord,
7796
+ sidecarRecord,
7797
+ };
7798
+ }
7799
+ /**
7800
+ * Create a main record and multiple sidecar records in sequence.
7801
+ *
7802
+ * This orchestrates the creation of a main record followed by one or more
7803
+ * sidecar records that reference it. This is useful for workflows like:
7804
+ * - Creating a project with multiple hypercert claims
7805
+ * - Creating a hypercert with evidence and evaluation records
7806
+ *
7807
+ * @param repo - The repository instance
7808
+ * @param params - Parameters including the main record and sidecar definitions
7809
+ * @returns The main record and all created sidecar records
7810
+ *
7811
+ * @example
7812
+ * ```typescript
7813
+ * const result = await createWithSidecars(repo, {
7814
+ * main: {
7815
+ * collection: "org.hypercerts.project",
7816
+ * record: {
7817
+ * $type: "org.hypercerts.project",
7818
+ * title: "Climate Initiative 2024",
7819
+ * description: "Our climate work",
7820
+ * createdAt: new Date().toISOString(),
7821
+ * },
7822
+ * },
7823
+ * sidecars: [
7824
+ * {
7825
+ * collection: "org.hypercerts.claim.activity",
7826
+ * record: {
7827
+ * $type: "org.hypercerts.claim.activity",
7828
+ * title: "Tree Planting",
7829
+ * // ... other hypercert fields
7830
+ * // Note: If you need to reference the main record, you must wait
7831
+ * // for result.main and then call batchCreateSidecars separately
7832
+ * },
7833
+ * },
7834
+ * {
7835
+ * collection: "org.hypercerts.claim.activity",
7836
+ * record: {
7837
+ * $type: "org.hypercerts.claim.activity",
7838
+ * title: "Carbon Measurement",
7839
+ * // ... other hypercert fields
7840
+ * },
7841
+ * },
7842
+ * ],
7843
+ * });
7844
+ *
7845
+ * console.log(result.main.uri); // Main project record
7846
+ * console.log(result.sidecars.length); // 2 hypercert sidecars
7847
+ * ```
7848
+ */
7849
+ async function createWithSidecars(repo, params) {
7850
+ // Create the main record first
7851
+ const main = await repo.records.create({
7852
+ collection: params.main.collection,
7853
+ record: params.main.record,
7854
+ rkey: params.main.rkey,
7855
+ });
7856
+ // Create all sidecar records
7857
+ const sidecars = [];
7858
+ for (const sidecar of params.sidecars) {
7859
+ const created = await repo.records.create({
7860
+ collection: sidecar.collection,
7861
+ record: sidecar.record,
7862
+ rkey: sidecar.rkey,
7863
+ });
7864
+ sidecars.push(created);
7865
+ }
7866
+ return { main, sidecars };
7867
+ }
7868
+ /**
7869
+ * Batch create multiple sidecar records.
7870
+ *
7871
+ * This is useful when you want to add multiple related records
7872
+ * efficiently. The sidecar records should already contain any necessary
7873
+ * references to the main record in their data.
7874
+ *
7875
+ * @param repo - The repository instance
7876
+ * @param sidecars - Array of sidecar definitions (records should include references)
7877
+ * @returns Array of created sidecar records
7878
+ *
7879
+ * @example
7880
+ * ```typescript
7881
+ * const hypercert = await repo.hypercerts.create({...});
7882
+ * const mainRef = { uri: hypercert.hypercertUri, cid: hypercert.hypercertCid };
7883
+ *
7884
+ * const evaluations = await batchCreateSidecars(repo, [
7885
+ * {
7886
+ * collection: "org.myapp.evaluation",
7887
+ * record: {
7888
+ * $type: "org.myapp.evaluation",
7889
+ * subject: mainRef, // Reference already included
7890
+ * score: 85,
7891
+ * methodology: "Peer review",
7892
+ * createdAt: new Date().toISOString(),
7893
+ * },
7894
+ * },
7895
+ * {
7896
+ * collection: "org.myapp.comment",
7897
+ * record: {
7898
+ * $type: "org.myapp.comment",
7899
+ * subject: mainRef, // Reference already included
7900
+ * text: "Great work!",
7901
+ * createdAt: new Date().toISOString(),
7902
+ * },
7903
+ * },
7904
+ * ]);
7905
+ * ```
7906
+ */
7907
+ async function batchCreateSidecars(repo, sidecars) {
7908
+ const results = [];
7909
+ for (const sidecar of sidecars) {
7910
+ const result = await createSidecarRecord(repo, sidecar.collection, sidecar.record, {
7911
+ rkey: sidecar.rkey,
7912
+ });
7913
+ results.push(result);
7914
+ }
7915
+ return results;
5667
7916
  }
5668
7917
 
5669
7918
  /**
@@ -5825,9 +8074,13 @@ Object.defineProperty(exports, "OrgHypercertsClaimCollection", {
5825
8074
  enumerable: true,
5826
8075
  get: function () { return lexicon.OrgHypercertsClaimCollection; }
5827
8076
  });
5828
- Object.defineProperty(exports, "OrgHypercertsClaimContribution", {
8077
+ Object.defineProperty(exports, "OrgHypercertsClaimContributionDetails", {
8078
+ enumerable: true,
8079
+ get: function () { return lexicon.OrgHypercertsClaimContributionDetails; }
8080
+ });
8081
+ Object.defineProperty(exports, "OrgHypercertsClaimContributorInformation", {
5829
8082
  enumerable: true,
5830
- get: function () { return lexicon.OrgHypercertsClaimContribution; }
8083
+ get: function () { return lexicon.OrgHypercertsClaimContributorInformation; }
5831
8084
  });
5832
8085
  Object.defineProperty(exports, "OrgHypercertsClaimEvaluation", {
5833
8086
  enumerable: true,
@@ -5841,10 +8094,6 @@ Object.defineProperty(exports, "OrgHypercertsClaimMeasurement", {
5841
8094
  enumerable: true,
5842
8095
  get: function () { return lexicon.OrgHypercertsClaimMeasurement; }
5843
8096
  });
5844
- Object.defineProperty(exports, "OrgHypercertsClaimProject", {
5845
- enumerable: true,
5846
- get: function () { return lexicon.OrgHypercertsClaimProject; }
5847
- });
5848
8097
  Object.defineProperty(exports, "OrgHypercertsClaimRights", {
5849
8098
  enumerable: true,
5850
8099
  get: function () { return lexicon.OrgHypercertsClaimRights; }
@@ -5853,6 +8102,10 @@ Object.defineProperty(exports, "OrgHypercertsFundingReceipt", {
5853
8102
  enumerable: true,
5854
8103
  get: function () { return lexicon.OrgHypercertsFundingReceipt; }
5855
8104
  });
8105
+ Object.defineProperty(exports, "OrgHypercertsHelperWorkScopeTag", {
8106
+ enumerable: true,
8107
+ get: function () { return lexicon.OrgHypercertsHelperWorkScopeTag; }
8108
+ });
5856
8109
  Object.defineProperty(exports, "validate", {
5857
8110
  enumerable: true,
5858
8111
  get: function () { return lexicon.validate; }
@@ -5865,6 +8118,7 @@ exports.AccountActionSchema = AccountActionSchema;
5865
8118
  exports.AccountAttrSchema = AccountAttrSchema;
5866
8119
  exports.AccountPermissionSchema = AccountPermissionSchema;
5867
8120
  exports.AuthenticationError = AuthenticationError;
8121
+ exports.BaseOperations = BaseOperations;
5868
8122
  exports.BlobPermissionSchema = BlobPermissionSchema;
5869
8123
  exports.CollaboratorPermissionsSchema = CollaboratorPermissionsSchema;
5870
8124
  exports.CollaboratorSchema = CollaboratorSchema;
@@ -5876,6 +8130,7 @@ exports.IdentityPermissionSchema = IdentityPermissionSchema;
5876
8130
  exports.InMemorySessionStore = InMemorySessionStore;
5877
8131
  exports.InMemoryStateStore = InMemoryStateStore;
5878
8132
  exports.IncludePermissionSchema = IncludePermissionSchema;
8133
+ exports.LexiconRegistry = LexiconRegistry;
5879
8134
  exports.MimeTypeSchema = MimeTypeSchema;
5880
8135
  exports.NetworkError = NetworkError;
5881
8136
  exports.NsidSchema = NsidSchema;
@@ -5895,13 +8150,37 @@ exports.TRANSITION_SCOPES = TRANSITION_SCOPES;
5895
8150
  exports.TimeoutConfigSchema = TimeoutConfigSchema;
5896
8151
  exports.TransitionScopeSchema = TransitionScopeSchema;
5897
8152
  exports.ValidationError = ValidationError;
8153
+ exports.attachSidecar = attachSidecar;
8154
+ exports.batchCreateSidecars = batchCreateSidecars;
8155
+ exports.buildAtUri = buildAtUri;
5898
8156
  exports.buildScope = buildScope;
5899
8157
  exports.createATProtoSDK = createATProtoSDK;
8158
+ exports.createArrayField = createArrayField;
8159
+ exports.createBlobField = createBlobField;
8160
+ exports.createBooleanField = createBooleanField;
8161
+ exports.createDatetimeField = createDatetimeField;
8162
+ exports.createIntegerField = createIntegerField;
8163
+ exports.createLexiconDoc = createLexiconDoc;
8164
+ exports.createNumberField = createNumberField;
8165
+ exports.createObjectField = createObjectField;
8166
+ exports.createRecordDef = createRecordDef;
8167
+ exports.createSidecarRecord = createSidecarRecord;
8168
+ exports.createStringField = createStringField;
8169
+ exports.createStrongRef = createStrongRef;
8170
+ exports.createStrongRefField = createStrongRefField;
8171
+ exports.createStrongRefFromResult = createStrongRefFromResult;
8172
+ exports.createWithSidecars = createWithSidecars;
8173
+ exports.extractRkeyFromUri = extractRkeyFromUri;
5900
8174
  exports.hasAllPermissions = hasAllPermissions;
5901
8175
  exports.hasAnyPermission = hasAnyPermission;
5902
8176
  exports.hasPermission = hasPermission;
8177
+ exports.isStrongRef = isStrongRef;
8178
+ exports.isValidAtUri = isValidAtUri;
5903
8179
  exports.mergeScopes = mergeScopes;
8180
+ exports.parseAtUri = parseAtUri;
5904
8181
  exports.parseScope = parseScope;
5905
8182
  exports.removePermissions = removePermissions;
8183
+ exports.validateLexiconStructure = validateLexiconStructure;
5906
8184
  exports.validateScope = validateScope;
8185
+ exports.validateStrongRef = validateStrongRef;
5907
8186
  //# sourceMappingURL=index.cjs.map