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

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,26 @@ 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
+ });
3579
+ if (!uploadResult.success) {
3580
+ throw new NetworkError("Failed to upload blob");
3581
+ }
3582
+ return uploadResult.data.blob;
3583
+ }
3054
3584
  /**
3055
3585
  * Uploads an image blob and returns a blob reference.
3056
3586
  *
@@ -3161,9 +3691,6 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3161
3691
  if (imageBlobRef) {
3162
3692
  hypercertRecord.image = imageBlobRef;
3163
3693
  }
3164
- if (params.evidence && params.evidence.length > 0) {
3165
- hypercertRecord.evidence = params.evidence;
3166
- }
3167
3694
  const hypercertValidation = lexicon.validate(hypercertRecord, HYPERCERT_COLLECTIONS.CLAIM, "main", false);
3168
3695
  if (!hypercertValidation.success) {
3169
3696
  throw new ValidationError(`Invalid hypercert record: ${hypercertValidation.error?.message}`);
@@ -3247,6 +3774,35 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3247
3774
  throw error;
3248
3775
  }
3249
3776
  }
3777
+ /**
3778
+ * Creates evidence records with progress tracking.
3779
+ *
3780
+ * @param hypercertUri - URI of the hypercert
3781
+ * @param evidenceItems - Array of evidence data (without subjectUri)
3782
+ * @param onProgress - Optional progress callback
3783
+ * @returns Promise resolving to array of evidence URIs
3784
+ * @internal
3785
+ */
3786
+ async createEvidenceWithProgress(hypercertUri, evidenceItems, onProgress) {
3787
+ this.emitProgress(onProgress, { name: "addEvidence", status: "start" });
3788
+ try {
3789
+ const evidenceUris = await Promise.all(evidenceItems.map((evidence) => this.addEvidence({
3790
+ subjectUri: hypercertUri,
3791
+ ...evidence,
3792
+ }).then((result) => result.uri)));
3793
+ this.emitProgress(onProgress, {
3794
+ name: "addEvidence",
3795
+ status: "success",
3796
+ data: { count: evidenceUris.length },
3797
+ });
3798
+ return evidenceUris;
3799
+ }
3800
+ catch (error) {
3801
+ this.emitProgress(onProgress, { name: "addEvidence", status: "error", error: error });
3802
+ this.logger?.warn(`Failed to create evidence: ${error instanceof Error ? error.message : "Unknown"}`);
3803
+ throw error;
3804
+ }
3805
+ }
3250
3806
  /**
3251
3807
  * Creates a new hypercert with all related records.
3252
3808
  *
@@ -3275,6 +3831,7 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3275
3831
  * - `createHypercert`: Main hypercert record creation
3276
3832
  * - `attachLocation`: Location record creation
3277
3833
  * - `createContributions`: Contribution records creation
3834
+ * - `addEvidence`: Evidence records creation
3278
3835
  *
3279
3836
  * @example Minimal hypercert
3280
3837
  * ```typescript
@@ -3350,6 +3907,15 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3350
3907
  // Error already logged and progress emitted
3351
3908
  }
3352
3909
  }
3910
+ // Step 6: Add evidence records if provided
3911
+ if (params.evidence && params.evidence.length > 0) {
3912
+ try {
3913
+ result.evidenceUris = await this.createEvidenceWithProgress(hypercertUri, params.evidence, params.onProgress);
3914
+ }
3915
+ catch {
3916
+ // Error already logged and progress emitted
3917
+ }
3918
+ }
3353
3919
  return result;
3354
3920
  }
3355
3921
  catch (error) {
@@ -3632,50 +4198,18 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3632
4198
  */
3633
4199
  async attachLocation(hypercertUri, location) {
3634
4200
  try {
3635
- // Validate required srs field
3636
4201
  if (!location.srs) {
3637
4202
  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
4203
  }
3639
4204
  // Validate that hypercert exists (unused but confirms hypercert is valid)
3640
4205
  await this.get(hypercertUri);
3641
4206
  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
4207
+ const locationData = await this.resolveUriOrBlob(location.location, "application/geo+json");
3674
4208
  const locationRecord = {
3675
4209
  $type: HYPERCERT_COLLECTIONS.LOCATION,
3676
- lpVersion: "1.0",
4210
+ lpVersion: location.lpVersion || "1.0",
3677
4211
  srs: location.srs,
3678
- locationType,
4212
+ locationType: location.locationType,
3679
4213
  location: locationData,
3680
4214
  createdAt,
3681
4215
  name: location.name,
@@ -3693,6 +4227,16 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3693
4227
  if (!result.success) {
3694
4228
  throw new NetworkError("Failed to attach location");
3695
4229
  }
4230
+ await this.update({
4231
+ uri: hypercertUri,
4232
+ updates: {
4233
+ location: {
4234
+ $type: "com.atproto.repo.strongRef",
4235
+ uri: result.data.uri,
4236
+ cid: result.data.cid,
4237
+ },
4238
+ },
4239
+ });
3696
4240
  this.emit("locationAttached", { uri: result.data.uri, cid: result.data.cid, hypercertUri });
3697
4241
  return { uri: result.data.uri, cid: result.data.cid };
3698
4242
  }
@@ -3703,36 +4247,75 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3703
4247
  }
3704
4248
  }
3705
4249
  /**
3706
- * Adds evidence to an existing hypercert.
4250
+ * Generic helper to resolve string | Blob into a URI or blob reference.
4251
+ *
4252
+ * @param content - Either a URI string or a Blob to upload
4253
+ * @param fallbackMimeType - MIME type to use if Blob.type is empty
4254
+ * @returns Promise resolving to either a URI ref or blob ref union type
4255
+ * @internal
4256
+ */
4257
+ async resolveUriOrBlob(content, fallbackMimeType) {
4258
+ if (typeof content === "string") {
4259
+ return {
4260
+ $type: "org.hypercerts.defs#uri",
4261
+ uri: content,
4262
+ };
4263
+ }
4264
+ else {
4265
+ const uploadedBlob = await this.handleBlobUpload(content, fallbackMimeType);
4266
+ return {
4267
+ $type: "org.hypercerts.defs#smallBlob",
4268
+ blob: uploadedBlob,
4269
+ };
4270
+ }
4271
+ }
4272
+ /**
4273
+ * Adds evidence to any subject via the subject ref.
3707
4274
  *
3708
- * @param hypercertUri - AT-URI of the hypercert
3709
- * @param evidence - Array of evidence items to add
4275
+ * @param evidence - HypercertEvidenceInput
3710
4276
  * @returns Promise resolving to update result
3711
4277
  * @throws {@link ValidationError} if validation fails
3712
4278
  * @throws {@link NetworkError} if the operation fails
3713
4279
  *
3714
- * @remarks
3715
- * Evidence is appended to existing evidence, not replaced.
3716
- *
3717
4280
  * @example
3718
4281
  * ```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
- * ]);
4282
+ * await repo.hypercerts.addEvidence({
4283
+ * subjectUri: "at://did:plc:u7h3dstby64di67bxaotzxcz/org.hypercerts.claim.activity/3mbvv5d7ixh2g"
4284
+ * content: Blob,
4285
+ * title: "Meeting Notes",
4286
+ * shortDescription: "Meetings notes from the 3rd of December 2025",
4287
+ * description: "The meeting with the board of directors and audience on 2025 in regards to the ecological landscape",
4288
+ * relationType: "supports",
4289
+ * })
3723
4290
  * ```
3724
4291
  */
3725
- async addEvidence(hypercertUri, evidence) {
4292
+ async addEvidence(evidence) {
3726
4293
  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 },
4294
+ const { subjectUri, content, ...rest } = evidence;
4295
+ const subject = await this.get(subjectUri);
4296
+ const createdAt = new Date().toISOString();
4297
+ const evidenceContent = await this.resolveUriOrBlob(content, "application/octet-stream");
4298
+ const evidenceRecord = {
4299
+ ...rest,
4300
+ $type: HYPERCERT_COLLECTIONS.EVIDENCE,
4301
+ createdAt,
4302
+ content: evidenceContent,
4303
+ subject: { uri: subject.uri, cid: subject.cid },
4304
+ };
4305
+ const validation = lexicon.validate(evidenceRecord, HYPERCERT_COLLECTIONS.EVIDENCE, "main", false);
4306
+ if (!validation.success) {
4307
+ throw new ValidationError(`Invalid evidence record: ${validation.error?.message}`);
4308
+ }
4309
+ const result = await this.agent.com.atproto.repo.createRecord({
4310
+ repo: this.repoDid,
4311
+ collection: HYPERCERT_COLLECTIONS.EVIDENCE,
4312
+ record: evidenceRecord,
3733
4313
  });
3734
- this.emit("evidenceAdded", { uri: result.uri, cid: result.cid });
3735
- return result;
4314
+ if (!result.success) {
4315
+ throw new NetworkError(`Failed to add evidence`);
4316
+ }
4317
+ this.emit("evidenceAdded", { uri: result.data.uri, cid: result.data.cid });
4318
+ return { uri: result.data.uri, cid: result.data.cid };
3736
4319
  }
3737
4320
  catch (error) {
3738
4321
  if (error instanceof ValidationError || error instanceof NetworkError)
@@ -3741,22 +4324,29 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3741
4324
  }
3742
4325
  }
3743
4326
  /**
3744
- * Creates a contribution record.
4327
+ * Creates a contribution details record.
4328
+ *
4329
+ * This creates a standalone contribution details record that can be referenced
4330
+ * from an activity's `contributors` array via a strong reference.
3745
4331
  *
3746
4332
  * @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")
4333
+ * @param params.hypercertUri - Optional hypercert (unused, kept for backward compatibility)
4334
+ * @param params.contributors - Array of contributor DIDs (unused, kept for backward compatibility)
4335
+ * @param params.role - Role of the contributor (e.g., "coordinator", "implementer")
3750
4336
  * @param params.description - Optional description of the contribution
3751
- * @returns Promise resolving to contribution record URI and CID
4337
+ * @returns Promise resolving to contribution details record URI and CID
3752
4338
  * @throws {@link ValidationError} if validation fails
3753
4339
  * @throws {@link NetworkError} if the operation fails
3754
4340
  *
4341
+ * @remarks
4342
+ * In the new lexicon structure, contributions are stored differently:
4343
+ * - Use `contributionDetails` for detailed contribution records (role, description, timeframe)
4344
+ * - Use `contributorInformation` for contributor profiles (identifier, displayName, image)
4345
+ * - Reference these from the activity's `contributors` array using strong refs
4346
+ *
3755
4347
  * @example
3756
4348
  * ```typescript
3757
4349
  * await repo.hypercerts.addContribution({
3758
- * hypercertUri: hypercertUri,
3759
- * contributors: ["did:plc:alice", "did:plc:bob"],
3760
4350
  * role: "implementer",
3761
4351
  * description: "On-ground implementation team",
3762
4352
  * });
@@ -3766,28 +4356,22 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3766
4356
  try {
3767
4357
  const createdAt = new Date().toISOString();
3768
4358
  const contributionRecord = {
3769
- $type: HYPERCERT_COLLECTIONS.CONTRIBUTION,
3770
- contributors: params.contributors,
4359
+ $type: HYPERCERT_COLLECTIONS.CONTRIBUTION_DETAILS,
3771
4360
  role: params.role,
3772
4361
  createdAt,
3773
- description: params.description,
3774
- hypercert: { uri: "", cid: "" }, // Will be set below if hypercertUri provided
4362
+ contributionDescription: params.description,
3775
4363
  };
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);
4364
+ const validation = lexicon.validate(contributionRecord, HYPERCERT_COLLECTIONS.CONTRIBUTION_DETAILS, "main", false);
3781
4365
  if (!validation.success) {
3782
- throw new ValidationError(`Invalid contribution record: ${validation.error?.message}`);
4366
+ throw new ValidationError(`Invalid contribution details record: ${validation.error?.message}`);
3783
4367
  }
3784
4368
  const result = await this.agent.com.atproto.repo.createRecord({
3785
4369
  repo: this.repoDid,
3786
- collection: HYPERCERT_COLLECTIONS.CONTRIBUTION,
4370
+ collection: HYPERCERT_COLLECTIONS.CONTRIBUTION_DETAILS,
3787
4371
  record: contributionRecord,
3788
4372
  });
3789
4373
  if (!result.success) {
3790
- throw new NetworkError("Failed to create contribution");
4374
+ throw new NetworkError("Failed to create contribution details");
3791
4375
  }
3792
4376
  this.emit("contributionCreated", { uri: result.data.uri, cid: result.data.cid });
3793
4377
  return { uri: result.data.uri, cid: result.data.cid };
@@ -3924,7 +4508,7 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3924
4508
  * @param params.title - Collection title
3925
4509
  * @param params.claims - Array of hypercert references with weights
3926
4510
  * @param params.shortDescription - Optional short description
3927
- * @param params.coverPhoto - Optional cover image blob
4511
+ * @param params.banner - Optional cover image blob
3928
4512
  * @returns Promise resolving to collection record URI and CID
3929
4513
  * @throws {@link ValidationError} if validation fails
3930
4514
  * @throws {@link NetworkError} if the operation fails
@@ -3939,22 +4523,22 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3939
4523
  * { uri: hypercert2Uri, cid: hypercert2Cid, weight: "0.3" },
3940
4524
  * { uri: hypercert3Uri, cid: hypercert3Cid, weight: "0.2" },
3941
4525
  * ],
3942
- * coverPhoto: coverImageBlob,
4526
+ * banner: coverImageBlob,
3943
4527
  * });
3944
4528
  * ```
3945
4529
  */
3946
4530
  async createCollection(params) {
3947
4531
  try {
3948
4532
  const createdAt = new Date().toISOString();
3949
- let coverPhotoRef;
3950
- if (params.coverPhoto) {
3951
- const arrayBuffer = await params.coverPhoto.arrayBuffer();
4533
+ let bannerRef;
4534
+ if (params.banner) {
4535
+ const arrayBuffer = await params.banner.arrayBuffer();
3952
4536
  const uint8Array = new Uint8Array(arrayBuffer);
3953
4537
  const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
3954
- encoding: params.coverPhoto.type || "image/jpeg",
4538
+ encoding: params.banner.type || "image/jpeg",
3955
4539
  });
3956
4540
  if (uploadResult.success) {
3957
- coverPhotoRef = {
4541
+ bannerRef = {
3958
4542
  $type: "blob",
3959
4543
  ref: { $link: uploadResult.data.blob.ref.toString() },
3960
4544
  mimeType: uploadResult.data.blob.mimeType,
@@ -3971,8 +4555,8 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3971
4555
  if (params.shortDescription) {
3972
4556
  collectionRecord.shortDescription = params.shortDescription;
3973
4557
  }
3974
- if (coverPhotoRef) {
3975
- collectionRecord.coverPhoto = coverPhotoRef;
4558
+ if (bannerRef) {
4559
+ collectionRecord.banner = bannerRef;
3976
4560
  }
3977
4561
  const validation = lexicon.validate(collectionRecord, HYPERCERT_COLLECTIONS.COLLECTION, "main", false);
3978
4562
  if (!validation.success) {
@@ -4083,190 +4667,606 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
4083
4667
  throw new NetworkError(`Failed to list collections: ${error instanceof Error ? error.message : "Unknown"}`, error);
4084
4668
  }
4085
4669
  }
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
4670
  /**
4213
- * Grants repository access to a user.
4671
+ * Creates a new project that organizes multiple hypercert activities.
4214
4672
  *
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
4673
+ * Projects are now implemented as collections with `type='project'`.
4219
4674
  *
4220
- * @remarks
4221
- * If the user already has access, their permissions are updated
4222
- * to the new role.
4675
+ * @param params - Project creation parameters
4676
+ * @returns Promise resolving to created project URI and CID
4223
4677
  *
4224
4678
  * @example
4225
4679
  * ```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",
4680
+ * const result = await repo.hypercerts.createProject({
4681
+ * title: "Climate Impact 2024",
4682
+ * shortDescription: "Year-long climate initiative",
4683
+ * activities: [
4684
+ * { uri: activity1Uri, cid: activity1Cid, weight: "0.6" },
4685
+ * { uri: activity2Uri, cid: activity2Cid, weight: "0.4" }
4686
+ * ]
4236
4687
  * });
4688
+ * console.log(`Created project: ${result.uri}`);
4237
4689
  * ```
4238
4690
  */
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({
4691
+ async createProject(params) {
4692
+ try {
4693
+ const createdAt = new Date().toISOString();
4694
+ // Upload avatar blob if provided
4695
+ let avatarRef;
4696
+ if (params.avatar) {
4697
+ const arrayBuffer = await params.avatar.arrayBuffer();
4698
+ const uint8Array = new Uint8Array(arrayBuffer);
4699
+ const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
4700
+ encoding: params.avatar.type || "image/jpeg",
4701
+ });
4702
+ if (uploadResult.success) {
4703
+ avatarRef = {
4704
+ $type: "blob",
4705
+ ref: { $link: uploadResult.data.blob.ref.toString() },
4706
+ mimeType: uploadResult.data.blob.mimeType,
4707
+ size: uploadResult.data.blob.size,
4708
+ };
4709
+ }
4710
+ else {
4711
+ throw new NetworkError("Failed to upload avatar image");
4712
+ }
4713
+ }
4714
+ // Upload banner blob if provided
4715
+ let bannerRef;
4716
+ if (params.banner) {
4717
+ const arrayBuffer = await params.banner.arrayBuffer();
4718
+ const uint8Array = new Uint8Array(arrayBuffer);
4719
+ const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
4720
+ encoding: params.banner.type || "image/jpeg",
4721
+ });
4722
+ if (uploadResult.success) {
4723
+ bannerRef = {
4724
+ $type: "blob",
4725
+ ref: { $link: uploadResult.data.blob.ref.toString() },
4726
+ mimeType: uploadResult.data.blob.mimeType,
4727
+ size: uploadResult.data.blob.size,
4728
+ };
4729
+ }
4730
+ else {
4731
+ throw new NetworkError("Failed to upload banner image");
4732
+ }
4733
+ }
4734
+ // Build project record as a collection with type='project'
4735
+ // Collections require 'items' array, so we map activities to items
4736
+ const items = params.activities?.map((a) => ({
4737
+ itemIdentifier: { uri: a.uri, cid: a.cid },
4738
+ itemWeight: a.weight,
4739
+ })) || [];
4740
+ const projectRecord = {
4741
+ $type: HYPERCERT_COLLECTIONS.COLLECTION,
4742
+ type: "project",
4743
+ title: params.title,
4744
+ shortDescription: params.shortDescription,
4745
+ items,
4746
+ createdAt,
4747
+ };
4748
+ // Add optional fields
4749
+ if (params.description) {
4750
+ projectRecord.description = params.description;
4751
+ }
4752
+ if (avatarRef) {
4753
+ projectRecord.avatar = avatarRef;
4754
+ }
4755
+ if (bannerRef) {
4756
+ projectRecord.banner = bannerRef;
4757
+ }
4758
+ // Validate against collection lexicon
4759
+ const validation = lexicon.validate(projectRecord, HYPERCERT_COLLECTIONS.COLLECTION, "main", false);
4760
+ if (!validation.success) {
4761
+ throw new ValidationError(`Invalid project record: ${validation.error?.message}`);
4762
+ }
4763
+ // Create record
4764
+ const result = await this.agent.com.atproto.repo.createRecord({
4245
4765
  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}`);
4766
+ collection: HYPERCERT_COLLECTIONS.COLLECTION,
4767
+ record: projectRecord,
4768
+ });
4769
+ if (!result.success) {
4770
+ throw new NetworkError("Failed to create project");
4771
+ }
4772
+ // Emit event
4773
+ this.emit("projectCreated", { uri: result.data.uri, cid: result.data.cid });
4774
+ return { uri: result.data.uri, cid: result.data.cid };
4775
+ }
4776
+ catch (error) {
4777
+ if (error instanceof ValidationError || error instanceof NetworkError)
4778
+ throw error;
4779
+ throw new NetworkError(`Failed to create project: ${error instanceof Error ? error.message : "Unknown"}`, error);
4252
4780
  }
4253
4781
  }
4254
4782
  /**
4255
- * Revokes repository access from a user.
4783
+ * Gets a project by its AT-URI.
4256
4784
  *
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
4785
+ * Projects are collections with `type='project'`.
4260
4786
  *
4261
- * @remarks
4262
- * - Cannot revoke access from the repository owner
4263
- * - Revoked access is recorded with a `revokedAt` timestamp
4787
+ * @param uri - AT-URI of the project
4788
+ * @returns Promise resolving to project data (as collection)
4264
4789
  *
4265
4790
  * @example
4266
4791
  * ```typescript
4267
- * await repo.collaborators.revoke({
4268
- * userDid: "did:plc:former-collaborator",
4269
- * });
4792
+ * const { record } = await repo.hypercerts.getProject(projectUri);
4793
+ * console.log(`${record.title}: ${record.items?.length || 0} activities`);
4794
+ * ```
4795
+ */
4796
+ async getProject(uri) {
4797
+ try {
4798
+ // Parse URI
4799
+ const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
4800
+ if (!uriMatch) {
4801
+ throw new ValidationError(`Invalid URI format: ${uri}`);
4802
+ }
4803
+ const [, , collection, rkey] = uriMatch;
4804
+ // Fetch record
4805
+ const result = await this.agent.com.atproto.repo.getRecord({
4806
+ repo: this.repoDid,
4807
+ collection,
4808
+ rkey,
4809
+ });
4810
+ if (!result.success) {
4811
+ throw new NetworkError("Failed to get project");
4812
+ }
4813
+ // Validate as collection
4814
+ const validation = lexicon.validate(result.data.value, HYPERCERT_COLLECTIONS.COLLECTION, "main", false);
4815
+ if (!validation.success) {
4816
+ throw new ValidationError(`Invalid project record format: ${validation.error?.message}`);
4817
+ }
4818
+ // Verify it's actually a project (collection with type='project')
4819
+ const record = result.data.value;
4820
+ if (record.type !== "project") {
4821
+ throw new ValidationError(`Record is not a project (type='${record.type}')`);
4822
+ }
4823
+ return {
4824
+ uri: result.data.uri,
4825
+ cid: result.data.cid ?? "",
4826
+ record,
4827
+ };
4828
+ }
4829
+ catch (error) {
4830
+ if (error instanceof ValidationError || error instanceof NetworkError)
4831
+ throw error;
4832
+ throw new NetworkError(`Failed to get project: ${error instanceof Error ? error.message : "Unknown"}`, error);
4833
+ }
4834
+ }
4835
+ /**
4836
+ * Lists all projects with optional pagination.
4837
+ *
4838
+ * Projects are collections with `type='project'`. This method filters
4839
+ * collections to only return those with type='project'.
4840
+ *
4841
+ * @param params - Optional pagination parameters
4842
+ * @returns Promise resolving to paginated list of projects
4843
+ *
4844
+ * @example
4845
+ * ```typescript
4846
+ * const { records } = await repo.hypercerts.listProjects();
4847
+ * for (const { record } of records) {
4848
+ * console.log(`${record.title}: ${record.shortDescription}`);
4849
+ * }
4850
+ * ```
4851
+ */
4852
+ async listProjects(params) {
4853
+ try {
4854
+ const limit = params?.limit;
4855
+ let cursor = params?.cursor;
4856
+ const allRecords = [];
4857
+ // Loop-fetch until we have enough projects or no more cursor
4858
+ while (!cursor || allRecords.length < (limit ?? Infinity)) {
4859
+ const result = await this.agent.com.atproto.repo.listRecords({
4860
+ repo: this.repoDid,
4861
+ collection: HYPERCERT_COLLECTIONS.COLLECTION,
4862
+ limit: limit ?? 50,
4863
+ cursor,
4864
+ });
4865
+ if (!result.success) {
4866
+ throw new NetworkError("Failed to list projects");
4867
+ }
4868
+ // Filter and collect project records
4869
+ for (const r of result.data.records ?? []) {
4870
+ const record = r.value;
4871
+ if (record.type === "project") {
4872
+ allRecords.push({ uri: r.uri, cid: r.cid, record });
4873
+ }
4874
+ // Stop if we've collected enough
4875
+ if (limit && allRecords.length >= limit)
4876
+ break;
4877
+ }
4878
+ // Update cursor; break if no more pages
4879
+ cursor = result.data.cursor;
4880
+ if (!cursor)
4881
+ break;
4882
+ }
4883
+ return { records: allRecords, cursor };
4884
+ }
4885
+ catch (error) {
4886
+ if (error instanceof NetworkError)
4887
+ throw error;
4888
+ throw new NetworkError(`Failed to list projects: ${error instanceof Error ? error.message : "Unknown"}`, error);
4889
+ }
4890
+ }
4891
+ /**
4892
+ * Updates an existing project.
4893
+ *
4894
+ * Projects are collections with `type='project'`.
4895
+ *
4896
+ * @param uri - AT-URI of the project to update
4897
+ * @param updates - Fields to update
4898
+ * @returns Promise resolving to updated project URI and CID
4899
+ *
4900
+ * @example
4901
+ * ```typescript
4902
+ * const result = await repo.hypercerts.updateProject(projectUri, {
4903
+ * title: "Updated Project Title",
4904
+ * shortDescription: "New description"
4905
+ * });
4906
+ * ```
4907
+ */
4908
+ async updateProject(uri, updates) {
4909
+ try {
4910
+ // Parse URI and fetch existing record
4911
+ const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
4912
+ if (!uriMatch) {
4913
+ throw new ValidationError(`Invalid URI format: ${uri}`);
4914
+ }
4915
+ const [, , collection, rkey] = uriMatch;
4916
+ const existing = await this.agent.com.atproto.repo.getRecord({
4917
+ repo: this.repoDid,
4918
+ collection,
4919
+ rkey,
4920
+ });
4921
+ if (!existing.success) {
4922
+ throw new NetworkError(`Project not found: ${uri}`);
4923
+ }
4924
+ const existingRecord = existing.data.value;
4925
+ // Verify it's actually a project
4926
+ if (existingRecord.type !== "project") {
4927
+ throw new ValidationError(`Record is not a project (type='${existingRecord.type}')`);
4928
+ }
4929
+ // Merge updates with existing record
4930
+ const recordForUpdate = {
4931
+ ...existingRecord,
4932
+ // MUST preserve type, createdAt, and items structure
4933
+ type: "project",
4934
+ createdAt: existingRecord.createdAt,
4935
+ };
4936
+ // Apply simple field updates
4937
+ if (updates.title !== undefined)
4938
+ recordForUpdate.title = updates.title;
4939
+ if (updates.shortDescription !== undefined)
4940
+ recordForUpdate.shortDescription = updates.shortDescription;
4941
+ if (updates.description !== undefined)
4942
+ recordForUpdate.description = updates.description;
4943
+ // Handle avatar update with three-way logic
4944
+ delete recordForUpdate.avatar;
4945
+ if (updates.avatar !== undefined) {
4946
+ if (updates.avatar === null) {
4947
+ // Remove avatar
4948
+ }
4949
+ else {
4950
+ const arrayBuffer = await updates.avatar.arrayBuffer();
4951
+ const uint8Array = new Uint8Array(arrayBuffer);
4952
+ const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
4953
+ encoding: updates.avatar.type || "image/jpeg",
4954
+ });
4955
+ if (uploadResult.success) {
4956
+ recordForUpdate.avatar = {
4957
+ $type: "blob",
4958
+ ref: uploadResult.data.blob.ref,
4959
+ mimeType: uploadResult.data.blob.mimeType,
4960
+ size: uploadResult.data.blob.size,
4961
+ };
4962
+ }
4963
+ else {
4964
+ throw new NetworkError("Failed to upload avatar image");
4965
+ }
4966
+ }
4967
+ }
4968
+ else if (existingRecord.avatar) {
4969
+ recordForUpdate.avatar = existingRecord.avatar;
4970
+ }
4971
+ // Handle banner update
4972
+ delete recordForUpdate.banner;
4973
+ if (updates.banner !== undefined) {
4974
+ if (updates.banner === null) {
4975
+ // Remove banner
4976
+ }
4977
+ else {
4978
+ const arrayBuffer = await updates.banner.arrayBuffer();
4979
+ const uint8Array = new Uint8Array(arrayBuffer);
4980
+ const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
4981
+ encoding: updates.banner.type || "image/jpeg",
4982
+ });
4983
+ if (uploadResult.success) {
4984
+ recordForUpdate.banner = {
4985
+ $type: "blob",
4986
+ ref: uploadResult.data.blob.ref,
4987
+ mimeType: uploadResult.data.blob.mimeType,
4988
+ size: uploadResult.data.blob.size,
4989
+ };
4990
+ }
4991
+ else {
4992
+ throw new NetworkError("Failed to upload banner image");
4993
+ }
4994
+ }
4995
+ }
4996
+ else if (existingRecord.banner) {
4997
+ recordForUpdate.banner = existingRecord.banner;
4998
+ }
4999
+ // Transform activities to items array
5000
+ if (updates.activities) {
5001
+ recordForUpdate.items = updates.activities.map((a) => ({
5002
+ itemIdentifier: { uri: a.uri, cid: a.cid },
5003
+ itemWeight: a.weight,
5004
+ }));
5005
+ }
5006
+ else {
5007
+ // Preserve existing items
5008
+ recordForUpdate.items = existingRecord.items;
5009
+ }
5010
+ // Validate merged record
5011
+ const validation = lexicon.validate(recordForUpdate, HYPERCERT_COLLECTIONS.COLLECTION, "main", false);
5012
+ if (!validation.success) {
5013
+ throw new ValidationError(`Invalid project record: ${validation.error?.message}`);
5014
+ }
5015
+ // Update record
5016
+ const result = await this.agent.com.atproto.repo.putRecord({
5017
+ repo: this.repoDid,
5018
+ collection,
5019
+ rkey,
5020
+ record: recordForUpdate,
5021
+ });
5022
+ if (!result.success) {
5023
+ throw new NetworkError("Failed to update project");
5024
+ }
5025
+ // Emit event
5026
+ this.emit("projectUpdated", { uri: result.data.uri, cid: result.data.cid });
5027
+ return { uri: result.data.uri, cid: result.data.cid };
5028
+ }
5029
+ catch (error) {
5030
+ if (error instanceof ValidationError || error instanceof NetworkError)
5031
+ throw error;
5032
+ throw new NetworkError(`Failed to update project: ${error instanceof Error ? error.message : "Unknown"}`, error);
5033
+ }
5034
+ }
5035
+ /**
5036
+ * Deletes a project.
5037
+ *
5038
+ * Projects are collections with `type='project'`.
5039
+ *
5040
+ * @param uri - AT-URI of the project to delete
5041
+ *
5042
+ * @example
5043
+ * ```typescript
5044
+ * await repo.hypercerts.deleteProject(projectUri);
5045
+ * console.log("Project deleted");
5046
+ * ```
5047
+ */
5048
+ async deleteProject(uri) {
5049
+ try {
5050
+ // Parse URI
5051
+ const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
5052
+ if (!uriMatch) {
5053
+ throw new ValidationError(`Invalid URI format: ${uri}`);
5054
+ }
5055
+ const [, , collection, rkey] = uriMatch;
5056
+ // Verify it's actually a project before deleting
5057
+ const existing = await this.agent.com.atproto.repo.getRecord({
5058
+ repo: this.repoDid,
5059
+ collection,
5060
+ rkey,
5061
+ });
5062
+ if (existing.success) {
5063
+ const record = existing.data.value;
5064
+ if (record.type !== "project") {
5065
+ throw new ValidationError(`Record is not a project (type='${record.type}')`);
5066
+ }
5067
+ }
5068
+ // Delete record
5069
+ const result = await this.agent.com.atproto.repo.deleteRecord({
5070
+ repo: this.repoDid,
5071
+ collection,
5072
+ rkey,
5073
+ });
5074
+ if (!result.success) {
5075
+ throw new NetworkError("Failed to delete project");
5076
+ }
5077
+ // Emit event
5078
+ this.emit("projectDeleted", { uri });
5079
+ }
5080
+ catch (error) {
5081
+ if (error instanceof ValidationError || error instanceof NetworkError)
5082
+ throw error;
5083
+ throw new NetworkError(`Failed to delete project: ${error instanceof Error ? error.message : "Unknown"}`, error);
5084
+ }
5085
+ }
5086
+ }
5087
+
5088
+ /**
5089
+ * CollaboratorOperationsImpl - SDS collaborator management operations.
5090
+ *
5091
+ * This module provides the implementation for managing collaborator
5092
+ * access on Shared Data Server (SDS) repositories.
5093
+ *
5094
+ * @packageDocumentation
5095
+ */
5096
+ /**
5097
+ * Implementation of collaborator operations for SDS access control.
5098
+ *
5099
+ * This class manages access permissions for shared repositories on
5100
+ * Shared Data Servers (SDS). It provides role-based access control
5101
+ * with predefined permission sets.
5102
+ *
5103
+ * @remarks
5104
+ * This class is typically not instantiated directly. Access it through
5105
+ * {@link Repository.collaborators} on an SDS-connected repository.
5106
+ *
5107
+ * **Role Hierarchy**:
5108
+ * - `viewer`: Read-only access
5109
+ * - `editor`: Read + Create + Update
5110
+ * - `admin`: All permissions except ownership transfer
5111
+ * - `owner`: Full control including ownership management
5112
+ *
5113
+ * **SDS API Endpoints Used**:
5114
+ * - `com.sds.repo.grantAccess`: Grant access to a user
5115
+ * - `com.sds.repo.revokeAccess`: Revoke access from a user
5116
+ * - `com.sds.repo.listCollaborators`: List all collaborators
5117
+ * - `com.sds.repo.getPermissions`: Get current user's permissions
5118
+ * - `com.sds.repo.transferOwnership`: Transfer repository ownership
5119
+ *
5120
+ * @example
5121
+ * ```typescript
5122
+ * // Get SDS repository
5123
+ * const sdsRepo = sdk.repository(session, { server: "sds" });
5124
+ *
5125
+ * // Grant editor access
5126
+ * await sdsRepo.collaborators.grant({
5127
+ * userDid: "did:plc:new-user",
5128
+ * role: "editor",
5129
+ * });
5130
+ *
5131
+ * // List all collaborators
5132
+ * const collaborators = await sdsRepo.collaborators.list();
5133
+ *
5134
+ * // Check specific user
5135
+ * const hasAccess = await sdsRepo.collaborators.hasAccess("did:plc:someone");
5136
+ * const role = await sdsRepo.collaborators.getRole("did:plc:someone");
5137
+ * ```
5138
+ *
5139
+ * @internal
5140
+ */
5141
+ class CollaboratorOperationsImpl {
5142
+ /**
5143
+ * Creates a new CollaboratorOperationsImpl.
5144
+ *
5145
+ * @param session - Authenticated OAuth session with fetchHandler
5146
+ * @param repoDid - DID of the repository to manage
5147
+ * @param serverUrl - SDS server URL
5148
+ *
5149
+ * @internal
5150
+ */
5151
+ constructor(session, repoDid, serverUrl) {
5152
+ this.session = session;
5153
+ this.repoDid = repoDid;
5154
+ this.serverUrl = serverUrl;
5155
+ }
5156
+ /**
5157
+ * Converts a role to its corresponding permissions object.
5158
+ *
5159
+ * @param role - The role to convert
5160
+ * @returns Permission flags for the role
5161
+ * @internal
5162
+ */
5163
+ roleToPermissions(role) {
5164
+ switch (role) {
5165
+ case "viewer":
5166
+ return { read: true, create: false, update: false, delete: false, admin: false, owner: false };
5167
+ case "editor":
5168
+ return { read: true, create: true, update: true, delete: false, admin: false, owner: false };
5169
+ case "admin":
5170
+ return { read: true, create: true, update: true, delete: true, admin: true, owner: false };
5171
+ case "owner":
5172
+ return { read: true, create: true, update: true, delete: true, admin: true, owner: true };
5173
+ }
5174
+ }
5175
+ /**
5176
+ * Determines the role from a permissions object.
5177
+ *
5178
+ * @param permissions - The permissions to analyze
5179
+ * @returns The highest role matching the permissions
5180
+ * @internal
5181
+ */
5182
+ permissionsToRole(permissions) {
5183
+ if (permissions.owner)
5184
+ return "owner";
5185
+ if (permissions.admin)
5186
+ return "admin";
5187
+ if (permissions.create || permissions.update)
5188
+ return "editor";
5189
+ return "viewer";
5190
+ }
5191
+ /**
5192
+ * Normalizes permissions from SDS API format to SDK format.
5193
+ *
5194
+ * The SDS API returns permissions as an object with boolean flags
5195
+ * (e.g., `{ read: true, create: true, update: false, ... }`).
5196
+ * This method ensures all expected fields are present with default values.
5197
+ *
5198
+ * @param permissions - Permissions object from SDS API
5199
+ * @returns Normalized permission flags object
5200
+ * @internal
5201
+ */
5202
+ parsePermissions(permissions) {
5203
+ return {
5204
+ read: permissions.read ?? false,
5205
+ create: permissions.create ?? false,
5206
+ update: permissions.update ?? false,
5207
+ delete: permissions.delete ?? false,
5208
+ admin: permissions.admin ?? false,
5209
+ owner: permissions.owner ?? false,
5210
+ };
5211
+ }
5212
+ /**
5213
+ * Grants repository access to a user.
5214
+ *
5215
+ * @param params - Grant parameters
5216
+ * @param params.userDid - DID of the user to grant access to
5217
+ * @param params.role - Role to assign (determines permissions)
5218
+ * @throws {@link NetworkError} if the grant operation fails
5219
+ *
5220
+ * @remarks
5221
+ * If the user already has access, their permissions are updated
5222
+ * to the new role.
5223
+ *
5224
+ * @example
5225
+ * ```typescript
5226
+ * // Grant viewer access
5227
+ * await repo.collaborators.grant({
5228
+ * userDid: "did:plc:viewer-user",
5229
+ * role: "viewer",
5230
+ * });
5231
+ *
5232
+ * // Upgrade to editor
5233
+ * await repo.collaborators.grant({
5234
+ * userDid: "did:plc:viewer-user",
5235
+ * role: "editor",
5236
+ * });
5237
+ * ```
5238
+ */
5239
+ async grant(params) {
5240
+ const permissions = this.roleToPermissions(params.role);
5241
+ const response = await this.session.fetchHandler(`${this.serverUrl}/xrpc/com.sds.repo.grantAccess`, {
5242
+ method: "POST",
5243
+ headers: { "Content-Type": "application/json" },
5244
+ body: JSON.stringify({
5245
+ repo: this.repoDid,
5246
+ userDid: params.userDid,
5247
+ permissions,
5248
+ }),
5249
+ });
5250
+ if (!response.ok) {
5251
+ throw new NetworkError(`Failed to grant access: ${response.statusText}`);
5252
+ }
5253
+ }
5254
+ /**
5255
+ * Revokes repository access from a user.
5256
+ *
5257
+ * @param params - Revoke parameters
5258
+ * @param params.userDid - DID of the user to revoke access from
5259
+ * @throws {@link NetworkError} if the revoke operation fails
5260
+ *
5261
+ * @remarks
5262
+ * - Cannot revoke access from the repository owner
5263
+ * - Revoked access is recorded with a `revokedAt` timestamp
5264
+ *
5265
+ * @example
5266
+ * ```typescript
5267
+ * await repo.collaborators.revoke({
5268
+ * userDid: "did:plc:former-collaborator",
5269
+ * });
4270
5270
  * ```
4271
5271
  */
4272
5272
  async revoke(params) {
@@ -4833,6 +5833,7 @@ class Repository {
4833
5833
  * @param repoDid - DID of the repository to operate on
4834
5834
  * @param isSDS - Whether this is a Shared Data Server
4835
5835
  * @param logger - Optional logger for debugging
5836
+ * @param lexiconRegistry - Registry for custom lexicon management
4836
5837
  *
4837
5838
  * @remarks
4838
5839
  * This constructor is typically not called directly. Use
@@ -4840,12 +5841,13 @@ class Repository {
4840
5841
  *
4841
5842
  * @internal
4842
5843
  */
4843
- constructor(session, serverUrl, repoDid, isSDS, logger) {
5844
+ constructor(session, serverUrl, repoDid, isSDS, logger, lexiconRegistry) {
4844
5845
  this.session = session;
4845
5846
  this.serverUrl = serverUrl;
4846
5847
  this.repoDid = repoDid;
4847
5848
  this._isSDS = isSDS;
4848
5849
  this.logger = logger;
5850
+ this.lexiconRegistry = lexiconRegistry || new LexiconRegistry();
4849
5851
  // Create a ConfigurableAgent that routes requests to the specified server URL
4850
5852
  // This allows routing to PDS, SDS, or any custom server while maintaining
4851
5853
  // the OAuth session's authentication
@@ -4920,7 +5922,53 @@ class Repository {
4920
5922
  * ```
4921
5923
  */
4922
5924
  repo(did) {
4923
- return new Repository(this.session, this.serverUrl, did, this._isSDS, this.logger);
5925
+ return new Repository(this.session, this.serverUrl, did, this._isSDS, this.logger, this.lexiconRegistry);
5926
+ }
5927
+ /**
5928
+ * Gets the LexiconRegistry instance for managing custom lexicons.
5929
+ *
5930
+ * The registry is shared across all operations in this repository and
5931
+ * enables validation of custom record types.
5932
+ *
5933
+ * @returns The {@link LexiconRegistry} instance
5934
+ *
5935
+ * @example
5936
+ * ```typescript
5937
+ * // Access the registry
5938
+ * const registry = repo.getLexiconRegistry();
5939
+ *
5940
+ * // Register a custom lexicon
5941
+ * registry.register({
5942
+ * lexicon: 1,
5943
+ * id: "org.myapp.evaluation",
5944
+ * defs: {
5945
+ * main: {
5946
+ * type: "record",
5947
+ * key: "tid",
5948
+ * record: {
5949
+ * type: "object",
5950
+ * required: ["$type", "score"],
5951
+ * properties: {
5952
+ * "$type": { type: "string", const: "org.myapp.evaluation" },
5953
+ * score: { type: "integer", minimum: 0, maximum: 100 }
5954
+ * }
5955
+ * }
5956
+ * }
5957
+ * }
5958
+ * });
5959
+ *
5960
+ * // Now create records using the custom lexicon
5961
+ * await repo.records.create({
5962
+ * collection: "org.myapp.evaluation",
5963
+ * record: {
5964
+ * $type: "org.myapp.evaluation",
5965
+ * score: 85
5966
+ * }
5967
+ * });
5968
+ * ```
5969
+ */
5970
+ getLexiconRegistry() {
5971
+ return this.lexiconRegistry;
4924
5972
  }
4925
5973
  /**
4926
5974
  * Low-level record operations for CRUD on any AT Protocol record type.
@@ -4953,7 +6001,7 @@ class Repository {
4953
6001
  */
4954
6002
  get records() {
4955
6003
  if (!this._records) {
4956
- this._records = new RecordOperationsImpl(this.agent, this.repoDid);
6004
+ this._records = new RecordOperationsImpl(this.agent, this.repoDid, this.lexiconRegistry);
4957
6005
  }
4958
6006
  return this._records;
4959
6007
  }
@@ -5130,26 +6178,67 @@ class Repository {
5130
6178
  }
5131
6179
  }
5132
6180
 
6181
+ /**
6182
+ * Custom URL validator that allows HTTP loopback addresses for development.
6183
+ *
6184
+ * Accepts:
6185
+ * - Any HTTPS URL (production)
6186
+ * - http://localhost (with optional port and path)
6187
+ * - http://127.0.0.1 (with optional port and path)
6188
+ * - http://[::1] (with optional port and path) - IPv6 loopback
6189
+ *
6190
+ * Rejects:
6191
+ * - Other HTTP URLs (e.g., http://example.com)
6192
+ * - Invalid URLs
6193
+ *
6194
+ * @internal
6195
+ */
6196
+ const urlOrLoopback = zod.z.string().refine((value) => {
6197
+ try {
6198
+ const url = new URL(value);
6199
+ // Always allow HTTPS
6200
+ if (url.protocol === "https:") {
6201
+ return true;
6202
+ }
6203
+ // For HTTP, only allow loopback addresses
6204
+ if (url.protocol === "http:") {
6205
+ const hostname = url.hostname.toLowerCase();
6206
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
6207
+ }
6208
+ return false;
6209
+ }
6210
+ catch {
6211
+ return false;
6212
+ }
6213
+ }, {
6214
+ message: "Must be a valid HTTPS URL or HTTP loopback URL (localhost, 127.0.0.1, [::1])",
6215
+ });
5133
6216
  /**
5134
6217
  * Zod schema for OAuth configuration validation.
5135
6218
  *
5136
6219
  * @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.
6220
+ * All URLs must be valid and use HTTPS in production. For local development,
6221
+ * HTTP loopback URLs (localhost, 127.0.0.1, [::1]) are allowed.
6222
+ * The `jwkPrivate` field should contain the private key in JWK (JSON Web Key) format as a string.
5139
6223
  */
5140
6224
  const OAuthConfigSchema = zod.z.object({
5141
6225
  /**
5142
6226
  * URL to the OAuth client metadata JSON document.
5143
6227
  * This document describes your application to the authorization server.
5144
6228
  *
6229
+ * For local development, you can use `http://localhost/` as a loopback client.
6230
+ *
5145
6231
  * @see https://atproto.com/specs/oauth#client-metadata
5146
6232
  */
5147
- clientId: zod.z.string().url(),
6233
+ clientId: urlOrLoopback,
5148
6234
  /**
5149
6235
  * URL where users are redirected after authentication.
5150
6236
  * Must match one of the redirect URIs in your client metadata.
6237
+ *
6238
+ * For local development, you can use HTTP loopback URLs like
6239
+ * `http://127.0.0.1:3000/callback` or `http://localhost:3000/callback`.
5151
6240
  */
5152
- redirectUri: zod.z.string().url(),
6241
+ redirectUri: urlOrLoopback,
5153
6242
  /**
5154
6243
  * OAuth scopes to request, space-separated.
5155
6244
  *
@@ -5183,8 +6272,11 @@ const OAuthConfigSchema = zod.z.object({
5183
6272
  /**
5184
6273
  * URL to your public JWKS (JSON Web Key Set) endpoint.
5185
6274
  * Used by the authorization server to verify your client's signatures.
6275
+ *
6276
+ * For local development, you can serve JWKS from a loopback URL like
6277
+ * `http://127.0.0.1:3000/.well-known/jwks.json`.
5186
6278
  */
5187
- jwksUri: zod.z.string().url(),
6279
+ jwksUri: urlOrLoopback,
5188
6280
  /**
5189
6281
  * Private JWK (JSON Web Key) as a JSON string.
5190
6282
  * Used for signing DPoP proofs and client assertions.
@@ -5194,28 +6286,64 @@ const OAuthConfigSchema = zod.z.object({
5194
6286
  * Typically loaded from environment variables or a secrets manager.
5195
6287
  */
5196
6288
  jwkPrivate: zod.z.string(),
6289
+ /**
6290
+ * Enable development mode features (optional).
6291
+ *
6292
+ * When true, suppresses warnings about using HTTP loopback URLs.
6293
+ * Should be set to true for local development to reduce console noise.
6294
+ *
6295
+ * @default false
6296
+ *
6297
+ * @example
6298
+ * ```typescript
6299
+ * oauth: {
6300
+ * clientId: "http://localhost/",
6301
+ * redirectUri: "http://127.0.0.1:3000/callback",
6302
+ * // ... other config
6303
+ * developmentMode: true, // Suppress loopback warnings
6304
+ * }
6305
+ * ```
6306
+ */
6307
+ developmentMode: zod.z.boolean().optional(),
5197
6308
  });
5198
6309
  /**
5199
6310
  * Zod schema for server URL configuration.
5200
6311
  *
5201
6312
  * @remarks
5202
6313
  * At least one server (PDS or SDS) should be configured for the SDK to be useful.
6314
+ * For local development, HTTP loopback URLs are allowed.
5203
6315
  */
5204
6316
  const ServerConfigSchema = zod.z.object({
5205
6317
  /**
5206
6318
  * Personal Data Server URL - the user's own AT Protocol server.
5207
6319
  * This is the primary server for user data operations.
5208
6320
  *
5209
- * @example "https://bsky.social"
6321
+ * @example Production
6322
+ * ```typescript
6323
+ * pds: "https://bsky.social"
6324
+ * ```
6325
+ *
6326
+ * @example Local development
6327
+ * ```typescript
6328
+ * pds: "http://localhost:2583"
6329
+ * ```
5210
6330
  */
5211
- pds: zod.z.string().url().optional(),
6331
+ pds: urlOrLoopback.optional(),
5212
6332
  /**
5213
6333
  * Shared Data Server URL - for collaborative data storage.
5214
6334
  * Required for collaborator and organization operations.
5215
6335
  *
5216
- * @example "https://sds.hypercerts.org"
6336
+ * @example Production
6337
+ * ```typescript
6338
+ * sds: "https://sds.hypercerts.org"
6339
+ * ```
6340
+ *
6341
+ * @example Local development
6342
+ * ```typescript
6343
+ * sds: "http://127.0.0.1:2584"
6344
+ * ```
5217
6345
  */
5218
- sds: zod.z.string().url().optional(),
6346
+ sds: urlOrLoopback.optional(),
5219
6347
  });
5220
6348
  /**
5221
6349
  * Zod schema for timeout configuration.
@@ -5342,6 +6470,10 @@ class ATProtoSDK {
5342
6470
  };
5343
6471
  this.config = configWithDefaults;
5344
6472
  this.logger = config.logger;
6473
+ // Initialize lexicon registry with hypercert lexicons
6474
+ // Filter out undefined lexicons (some may not be exported from lexicon package yet)
6475
+ const validLexicons = HYPERCERT_LEXICONS.filter((lex) => lex !== undefined);
6476
+ this.lexiconRegistry = new LexiconRegistry(validLexicons);
5345
6477
  // Initialize OAuth client
5346
6478
  this.oauthClient = new OAuthClient(configWithDefaults);
5347
6479
  this.logger?.info("ATProto SDK initialized");
@@ -5625,45 +6757,1160 @@ class ATProtoSDK {
5625
6757
  }
5626
6758
  // Get repository DID (default to session DID)
5627
6759
  const repoDid = session.did || session.sub;
5628
- return new Repository(session, serverUrl, repoDid, isSDS, this.logger);
6760
+ return new Repository(session, serverUrl, repoDid, isSDS, this.logger, this.lexiconRegistry);
6761
+ }
6762
+ /**
6763
+ * Gets the LexiconRegistry instance for managing custom lexicons.
6764
+ *
6765
+ * The registry allows you to register custom lexicon schemas and validate
6766
+ * records against them. All registered lexicons will be automatically
6767
+ * validated during record creation operations.
6768
+ *
6769
+ * @returns The {@link LexiconRegistry} instance
6770
+ *
6771
+ * @example
6772
+ * ```typescript
6773
+ * // Register a custom lexicon
6774
+ * const registry = sdk.getLexiconRegistry();
6775
+ * registry.register({
6776
+ * lexicon: 1,
6777
+ * id: "org.myapp.customRecord",
6778
+ * defs: { ... }
6779
+ * });
6780
+ *
6781
+ * // Check if lexicon is registered
6782
+ * if (registry.isRegistered("org.myapp.customRecord")) {
6783
+ * console.log("Custom lexicon is available");
6784
+ * }
6785
+ * ```
6786
+ */
6787
+ getLexiconRegistry() {
6788
+ return this.lexiconRegistry;
6789
+ }
6790
+ /**
6791
+ * The configured PDS (Personal Data Server) URL.
6792
+ *
6793
+ * @returns The PDS URL if configured, otherwise `undefined`
6794
+ */
6795
+ get pdsUrl() {
6796
+ return this.config.servers?.pds;
6797
+ }
6798
+ /**
6799
+ * The configured SDS (Shared Data Server) URL.
6800
+ *
6801
+ * @returns The SDS URL if configured, otherwise `undefined`
6802
+ */
6803
+ get sdsUrl() {
6804
+ return this.config.servers?.sds;
6805
+ }
6806
+ }
6807
+ /**
6808
+ * Factory function to create an ATProto SDK instance.
6809
+ *
6810
+ * This is a convenience function equivalent to `new ATProtoSDK(config)`.
6811
+ *
6812
+ * @param config - SDK configuration
6813
+ * @returns A new {@link ATProtoSDK} instance
6814
+ *
6815
+ * @example
6816
+ * ```typescript
6817
+ * import { createATProtoSDK } from "@hypercerts-org/sdk";
6818
+ *
6819
+ * const sdk = createATProtoSDK({
6820
+ * oauth: { ... },
6821
+ * servers: { pds: "https://bsky.social" },
6822
+ * });
6823
+ * ```
6824
+ */
6825
+ function createATProtoSDK(config) {
6826
+ return new ATProtoSDK(config);
6827
+ }
6828
+
6829
+ /**
6830
+ * BaseOperations - Abstract base class for custom lexicon operations.
6831
+ *
6832
+ * This module provides a foundation for building domain-specific operation
6833
+ * classes that work with custom lexicons. It handles validation, record
6834
+ * creation, and provides utilities for working with AT Protocol records.
6835
+ *
6836
+ * @packageDocumentation
6837
+ */
6838
+ /**
6839
+ * Abstract base class for creating custom lexicon operation classes.
6840
+ *
6841
+ * Extend this class to build domain-specific operations for your custom
6842
+ * lexicons. The base class provides:
6843
+ *
6844
+ * - Automatic validation against registered lexicon schemas
6845
+ * - Helper methods for creating and updating records
6846
+ * - Utilities for building strongRefs and AT-URIs
6847
+ * - Error handling and logging support
6848
+ *
6849
+ * @typeParam TParams - Type of parameters accepted by the create() method
6850
+ * @typeParam TResult - Type of result returned by the create() method
6851
+ *
6852
+ * @remarks
6853
+ * This class is designed to be extended by developers creating custom
6854
+ * operation classes for their own lexicons. It follows the same patterns
6855
+ * as the built-in hypercert operations.
6856
+ *
6857
+ * @example Basic usage
6858
+ * ```typescript
6859
+ * import { BaseOperations } from "@hypercerts-org/sdk-core";
6860
+ *
6861
+ * interface EvaluationParams {
6862
+ * subjectUri: string;
6863
+ * subjectCid: string;
6864
+ * score: number;
6865
+ * methodology?: string;
6866
+ * }
6867
+ *
6868
+ * interface EvaluationResult {
6869
+ * uri: string;
6870
+ * cid: string;
6871
+ * record: MyEvaluation;
6872
+ * }
6873
+ *
6874
+ * class EvaluationOperations extends BaseOperations<EvaluationParams, EvaluationResult> {
6875
+ * async create(params: EvaluationParams): Promise<EvaluationResult> {
6876
+ * const record = {
6877
+ * $type: "org.myapp.evaluation",
6878
+ * subject: this.createStrongRef(params.subjectUri, params.subjectCid),
6879
+ * score: params.score,
6880
+ * methodology: params.methodology,
6881
+ * createdAt: new Date().toISOString(),
6882
+ * };
6883
+ *
6884
+ * const { uri, cid } = await this.validateAndCreate("org.myapp.evaluation", record);
6885
+ * return { uri, cid, record };
6886
+ * }
6887
+ * }
6888
+ * ```
6889
+ *
6890
+ * @example With validation and error handling
6891
+ * ```typescript
6892
+ * class ProjectOperations extends BaseOperations<CreateProjectParams, ProjectResult> {
6893
+ * async create(params: CreateProjectParams): Promise<ProjectResult> {
6894
+ * // Validate input parameters
6895
+ * if (!params.title || params.title.trim().length === 0) {
6896
+ * throw new ValidationError("Project title cannot be empty");
6897
+ * }
6898
+ *
6899
+ * const record = {
6900
+ * $type: "org.myapp.project",
6901
+ * title: params.title,
6902
+ * description: params.description,
6903
+ * createdAt: new Date().toISOString(),
6904
+ * };
6905
+ *
6906
+ * try {
6907
+ * const { uri, cid } = await this.validateAndCreate("org.myapp.project", record);
6908
+ * this.logger?.info(`Created project: ${uri}`);
6909
+ * return { uri, cid, record };
6910
+ * } catch (error) {
6911
+ * this.logger?.error(`Failed to create project: ${error}`);
6912
+ * throw error;
6913
+ * }
6914
+ * }
6915
+ * }
6916
+ * ```
6917
+ */
6918
+ class BaseOperations {
6919
+ /**
6920
+ * Creates a new BaseOperations instance.
6921
+ *
6922
+ * @param agent - AT Protocol Agent for making API calls
6923
+ * @param repoDid - DID of the repository to operate on
6924
+ * @param lexiconRegistry - Registry for validating records against lexicon schemas
6925
+ * @param logger - Optional logger for debugging and monitoring
6926
+ *
6927
+ * @internal
6928
+ */
6929
+ constructor(agent, repoDid, lexiconRegistry, logger) {
6930
+ this.agent = agent;
6931
+ this.repoDid = repoDid;
6932
+ this.lexiconRegistry = lexiconRegistry;
6933
+ this.logger = logger;
6934
+ }
6935
+ /**
6936
+ * Validates a record against its lexicon schema and creates it in the repository.
6937
+ *
6938
+ * This method performs the following steps:
6939
+ * 1. Validates the record against the registered lexicon schema
6940
+ * 2. Throws ValidationError if validation fails
6941
+ * 3. Creates the record using the AT Protocol Agent
6942
+ * 4. Returns the created record's URI and CID
6943
+ *
6944
+ * @param collection - NSID of the collection (e.g., "org.myapp.customRecord")
6945
+ * @param record - Record data conforming to the collection's lexicon schema
6946
+ * @param rkey - Optional record key. If not provided, a TID is auto-generated
6947
+ * @returns Promise resolving to the created record's URI and CID
6948
+ * @throws {@link ValidationError} if the record doesn't conform to the lexicon schema
6949
+ * @throws {@link NetworkError} if the API request fails
6950
+ *
6951
+ * @example
6952
+ * ```typescript
6953
+ * const record = {
6954
+ * $type: "org.myapp.evaluation",
6955
+ * subject: { uri: "at://...", cid: "bafyrei..." },
6956
+ * score: 85,
6957
+ * createdAt: new Date().toISOString(),
6958
+ * };
6959
+ *
6960
+ * const { uri, cid } = await this.validateAndCreate("org.myapp.evaluation", record);
6961
+ * ```
6962
+ */
6963
+ async validateAndCreate(collection, record, rkey) {
6964
+ // Validate record against registered lexicon
6965
+ if (this.lexiconRegistry.isRegistered(collection)) {
6966
+ const validation = this.lexiconRegistry.validate(collection, record);
6967
+ if (!validation.valid) {
6968
+ throw new ValidationError(`Invalid ${collection}: ${validation.error}`);
6969
+ }
6970
+ }
6971
+ try {
6972
+ const result = await this.agent.com.atproto.repo.createRecord({
6973
+ repo: this.repoDid,
6974
+ collection,
6975
+ record: record,
6976
+ rkey,
6977
+ });
6978
+ if (!result.success) {
6979
+ throw new NetworkError("Failed to create record");
6980
+ }
6981
+ return { uri: result.data.uri, cid: result.data.cid };
6982
+ }
6983
+ catch (error) {
6984
+ if (error instanceof ValidationError || error instanceof NetworkError) {
6985
+ throw error;
6986
+ }
6987
+ throw new NetworkError(`Failed to create ${collection}: ${error instanceof Error ? error.message : "Unknown error"}`, error);
6988
+ }
6989
+ }
6990
+ /**
6991
+ * Validates a record against its lexicon schema and updates it in the repository.
6992
+ *
6993
+ * This method performs the following steps:
6994
+ * 1. Validates the record against the registered lexicon schema
6995
+ * 2. Throws ValidationError if validation fails
6996
+ * 3. Updates the record using the AT Protocol Agent
6997
+ * 4. Returns the updated record's URI and new CID
6998
+ *
6999
+ * @param collection - NSID of the collection
7000
+ * @param rkey - Record key (the last segment of the AT-URI)
7001
+ * @param record - New record data (completely replaces existing record)
7002
+ * @returns Promise resolving to the updated record's URI and new CID
7003
+ * @throws {@link ValidationError} if the record doesn't conform to the lexicon schema
7004
+ * @throws {@link NetworkError} if the API request fails
7005
+ *
7006
+ * @remarks
7007
+ * This is a full replacement operation, not a partial update.
7008
+ *
7009
+ * @example
7010
+ * ```typescript
7011
+ * const updatedRecord = {
7012
+ * $type: "org.myapp.evaluation",
7013
+ * subject: { uri: "at://...", cid: "bafyrei..." },
7014
+ * score: 90, // Updated score
7015
+ * createdAt: existingRecord.createdAt,
7016
+ * };
7017
+ *
7018
+ * const { uri, cid } = await this.validateAndUpdate(
7019
+ * "org.myapp.evaluation",
7020
+ * "abc123",
7021
+ * updatedRecord
7022
+ * );
7023
+ * ```
7024
+ */
7025
+ async validateAndUpdate(collection, rkey, record) {
7026
+ // Validate record against registered lexicon
7027
+ if (this.lexiconRegistry.isRegistered(collection)) {
7028
+ const validation = this.lexiconRegistry.validate(collection, record);
7029
+ if (!validation.valid) {
7030
+ throw new ValidationError(`Invalid ${collection}: ${validation.error}`);
7031
+ }
7032
+ }
7033
+ try {
7034
+ const result = await this.agent.com.atproto.repo.putRecord({
7035
+ repo: this.repoDid,
7036
+ collection,
7037
+ rkey,
7038
+ record: record,
7039
+ });
7040
+ if (!result.success) {
7041
+ throw new NetworkError("Failed to update record");
7042
+ }
7043
+ return { uri: result.data.uri, cid: result.data.cid };
7044
+ }
7045
+ catch (error) {
7046
+ if (error instanceof ValidationError || error instanceof NetworkError) {
7047
+ throw error;
7048
+ }
7049
+ throw new NetworkError(`Failed to update ${collection}: ${error instanceof Error ? error.message : "Unknown error"}`, error);
7050
+ }
7051
+ }
7052
+ /**
7053
+ * Creates a strongRef object from a URI and CID.
7054
+ *
7055
+ * StrongRefs are used in AT Protocol to reference specific versions
7056
+ * of records. They ensure that references point to an exact record
7057
+ * version, not just the latest version.
7058
+ *
7059
+ * @param uri - AT-URI of the record (e.g., "at://did:plc:abc/collection/rkey")
7060
+ * @param cid - Content Identifier (CID) of the record
7061
+ * @returns StrongRef object with uri and cid properties
7062
+ *
7063
+ * @example
7064
+ * ```typescript
7065
+ * const hypercertRef = this.createStrongRef(
7066
+ * "at://did:plc:abc123/org.hypercerts.claim.activity/xyz789",
7067
+ * "bafyreiabc123..."
7068
+ * );
7069
+ *
7070
+ * const evaluation = {
7071
+ * $type: "org.myapp.evaluation",
7072
+ * subject: hypercertRef, // Reference to specific hypercert version
7073
+ * score: 85,
7074
+ * createdAt: new Date().toISOString(),
7075
+ * };
7076
+ * ```
7077
+ */
7078
+ createStrongRef(uri, cid) {
7079
+ return { uri, cid };
7080
+ }
7081
+ /**
7082
+ * Creates a strongRef from a CreateResult or UpdateResult.
7083
+ *
7084
+ * This is a convenience method for creating strongRefs from the
7085
+ * results of create or update operations.
7086
+ *
7087
+ * @param result - Result from a create or update operation
7088
+ * @returns StrongRef object with uri and cid properties
7089
+ *
7090
+ * @example
7091
+ * ```typescript
7092
+ * // Create a project
7093
+ * const projectResult = await this.validateAndCreate("org.myapp.project", projectRecord);
7094
+ *
7095
+ * // Create a task that references the project
7096
+ * const taskRecord = {
7097
+ * $type: "org.myapp.task",
7098
+ * project: this.createStrongRefFromResult(projectResult),
7099
+ * title: "Implement feature",
7100
+ * createdAt: new Date().toISOString(),
7101
+ * };
7102
+ * ```
7103
+ */
7104
+ createStrongRefFromResult(result) {
7105
+ return { uri: result.uri, cid: result.cid };
7106
+ }
7107
+ /**
7108
+ * Parses an AT-URI to extract its components.
7109
+ *
7110
+ * AT-URIs follow the format: `at://{did}/{collection}/{rkey}`
7111
+ *
7112
+ * @param uri - AT-URI to parse
7113
+ * @returns Object containing did, collection, and rkey
7114
+ * @throws Error if the URI format is invalid
7115
+ *
7116
+ * @example
7117
+ * ```typescript
7118
+ * const { did, collection, rkey } = this.parseAtUri(
7119
+ * "at://did:plc:abc123/org.hypercerts.claim.activity/xyz789"
7120
+ * );
7121
+ * // did: "did:plc:abc123"
7122
+ * // collection: "org.hypercerts.claim.activity"
7123
+ * // rkey: "xyz789"
7124
+ * ```
7125
+ */
7126
+ parseAtUri(uri) {
7127
+ if (!uri.startsWith("at://")) {
7128
+ throw new Error(`Invalid AT-URI format: ${uri}`);
7129
+ }
7130
+ const parts = uri.slice(5).split("/"); // Remove "at://" and split
7131
+ if (parts.length !== 3) {
7132
+ throw new Error(`Invalid AT-URI format: ${uri}`);
7133
+ }
7134
+ return {
7135
+ did: parts[0],
7136
+ collection: parts[1],
7137
+ rkey: parts[2],
7138
+ };
7139
+ }
7140
+ /**
7141
+ * Builds an AT-URI from its components.
7142
+ *
7143
+ * @param did - DID of the repository
7144
+ * @param collection - NSID of the collection
7145
+ * @param rkey - Record key (typically a TID)
7146
+ * @returns Complete AT-URI string
7147
+ *
7148
+ * @example
7149
+ * ```typescript
7150
+ * const uri = this.buildAtUri(
7151
+ * "did:plc:abc123",
7152
+ * "org.myapp.evaluation",
7153
+ * "xyz789"
7154
+ * );
7155
+ * // Returns: "at://did:plc:abc123/org.myapp.evaluation/xyz789"
7156
+ * ```
7157
+ */
7158
+ buildAtUri(did, collection, rkey) {
7159
+ return `at://${did}/${collection}/${rkey}`;
7160
+ }
7161
+ }
7162
+
7163
+ /**
7164
+ * Lexicon Development Utilities - AT-URI and StrongRef Helpers
7165
+ *
7166
+ * This module provides utilities for working with AT Protocol URIs and strongRefs
7167
+ * when building custom lexicons. These tools help developers create type-safe
7168
+ * references between records.
7169
+ *
7170
+ * @packageDocumentation
7171
+ */
7172
+ /**
7173
+ * Parse an AT-URI into its component parts.
7174
+ *
7175
+ * Extracts the DID, collection NSID, and record key from an AT-URI string.
7176
+ * AT-URIs follow the format: `at://{did}/{collection}/{rkey}`
7177
+ *
7178
+ * @param uri - The AT-URI to parse
7179
+ * @returns The components of the URI
7180
+ * @throws {Error} If the URI format is invalid
7181
+ *
7182
+ * @example
7183
+ * ```typescript
7184
+ * const components = parseAtUri("at://did:plc:abc123/org.hypercerts.claim.activity/3km2vj4kfqp2a");
7185
+ * console.log(components);
7186
+ * // {
7187
+ * // did: "did:plc:abc123",
7188
+ * // collection: "org.hypercerts.claim.activity",
7189
+ * // rkey: "3km2vj4kfqp2a"
7190
+ * // }
7191
+ * ```
7192
+ */
7193
+ function parseAtUri(uri) {
7194
+ if (!uri.startsWith("at://")) {
7195
+ throw new Error(`Invalid AT-URI format: must start with "at://", got "${uri}"`);
7196
+ }
7197
+ const withoutProtocol = uri.slice(5); // Remove "at://"
7198
+ const parts = withoutProtocol.split("/");
7199
+ if (parts.length !== 3) {
7200
+ throw new Error(`Invalid AT-URI format: expected "at://{did}/{collection}/{rkey}", got "${uri}"`);
7201
+ }
7202
+ const [did, collection, rkey] = parts;
7203
+ if (!did || !collection || !rkey) {
7204
+ throw new Error(`Invalid AT-URI format: all components must be non-empty, got "${uri}"`);
7205
+ }
7206
+ return { did, collection, rkey };
7207
+ }
7208
+ /**
7209
+ * Build an AT-URI from its component parts.
7210
+ *
7211
+ * Constructs a valid AT-URI string from a DID, collection NSID, and record key.
7212
+ * The resulting URI follows the format: `at://{did}/{collection}/{rkey}`
7213
+ *
7214
+ * @param did - The repository owner's DID
7215
+ * @param collection - The collection NSID (lexicon identifier)
7216
+ * @param rkey - The record key (TID or custom string)
7217
+ * @returns The complete AT-URI
7218
+ *
7219
+ * @example
7220
+ * ```typescript
7221
+ * const uri = buildAtUri(
7222
+ * "did:plc:abc123",
7223
+ * "org.hypercerts.claim.activity",
7224
+ * "3km2vj4kfqp2a"
7225
+ * );
7226
+ * console.log(uri); // "at://did:plc:abc123/org.hypercerts.claim.activity/3km2vj4kfqp2a"
7227
+ * ```
7228
+ */
7229
+ function buildAtUri(did, collection, rkey) {
7230
+ if (!did || !collection || !rkey) {
7231
+ throw new Error("All AT-URI components (did, collection, rkey) must be non-empty");
7232
+ }
7233
+ return `at://${did}/${collection}/${rkey}`;
7234
+ }
7235
+ /**
7236
+ * Extract the record key (TID or custom key) from an AT-URI.
7237
+ *
7238
+ * Returns the last component of the AT-URI, which is the record key.
7239
+ * This is equivalent to `parseAtUri(uri).rkey` but more efficient.
7240
+ *
7241
+ * @param uri - The AT-URI to extract from
7242
+ * @returns The record key (TID or custom string)
7243
+ * @throws {Error} If the URI format is invalid
7244
+ *
7245
+ * @example
7246
+ * ```typescript
7247
+ * const rkey = extractRkeyFromUri("at://did:plc:abc123/org.hypercerts.claim.activity/3km2vj4kfqp2a");
7248
+ * console.log(rkey); // "3km2vj4kfqp2a"
7249
+ * ```
7250
+ */
7251
+ function extractRkeyFromUri(uri) {
7252
+ const { rkey } = parseAtUri(uri);
7253
+ return rkey;
7254
+ }
7255
+ /**
7256
+ * Check if a string is a valid AT-URI format.
7257
+ *
7258
+ * Validates that the string follows the AT-URI format without throwing errors.
7259
+ * This is useful for input validation before parsing.
7260
+ *
7261
+ * @param uri - The string to validate
7262
+ * @returns True if the string is a valid AT-URI, false otherwise
7263
+ *
7264
+ * @example
7265
+ * ```typescript
7266
+ * if (isValidAtUri(userInput)) {
7267
+ * const components = parseAtUri(userInput);
7268
+ * // ... use components
7269
+ * } else {
7270
+ * console.error("Invalid AT-URI");
7271
+ * }
7272
+ * ```
7273
+ */
7274
+ function isValidAtUri(uri) {
7275
+ try {
7276
+ parseAtUri(uri);
7277
+ return true;
7278
+ }
7279
+ catch {
7280
+ return false;
5629
7281
  }
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;
7282
+ }
7283
+ /**
7284
+ * Create a strongRef from a URI and CID.
7285
+ *
7286
+ * StrongRefs are the canonical way to reference specific versions of records
7287
+ * in AT Protocol. They combine an AT-URI (which identifies the record) with
7288
+ * a CID (which identifies the specific version).
7289
+ *
7290
+ * @param uri - The AT-URI of the record
7291
+ * @param cid - The CID (Content Identifier) of the record version
7292
+ * @returns A strongRef object
7293
+ *
7294
+ * @example
7295
+ * ```typescript
7296
+ * const ref = createStrongRef(
7297
+ * "at://did:plc:abc123/org.hypercerts.claim.activity/3km2vj4kfqp2a",
7298
+ * "bafyreiabc123..."
7299
+ * );
7300
+ * console.log(ref);
7301
+ * // {
7302
+ * // uri: "at://did:plc:abc123/org.hypercerts.claim.activity/3km2vj4kfqp2a",
7303
+ * // cid: "bafyreiabc123..."
7304
+ * // }
7305
+ * ```
7306
+ */
7307
+ function createStrongRef(uri, cid) {
7308
+ if (!uri || !cid) {
7309
+ throw new Error("Both uri and cid are required to create a strongRef");
5637
7310
  }
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;
7311
+ return { uri, cid };
7312
+ }
7313
+ /**
7314
+ * Create a strongRef from a CreateResult or UpdateResult.
7315
+ *
7316
+ * This is a convenience function that extracts the URI and CID from
7317
+ * the result of a record creation or update operation.
7318
+ *
7319
+ * @param result - The result from creating or updating a record
7320
+ * @returns A strongRef object
7321
+ *
7322
+ * @example
7323
+ * ```typescript
7324
+ * const hypercert = await repo.hypercerts.create({
7325
+ * title: "Climate Research",
7326
+ * // ... other params
7327
+ * });
7328
+ *
7329
+ * const ref = createStrongRefFromResult(hypercert);
7330
+ * // Now use ref in another record to reference this hypercert
7331
+ * ```
7332
+ */
7333
+ function createStrongRefFromResult(result) {
7334
+ return createStrongRef(result.uri, result.cid);
7335
+ }
7336
+ /**
7337
+ * Validate that an object is a valid strongRef.
7338
+ *
7339
+ * Checks that the object has the required `uri` and `cid` properties
7340
+ * and that they are non-empty strings.
7341
+ *
7342
+ * @param ref - The object to validate
7343
+ * @returns True if the object is a valid strongRef, false otherwise
7344
+ *
7345
+ * @example
7346
+ * ```typescript
7347
+ * const maybeRef = { uri: "at://...", cid: "bafyrei..." };
7348
+ * if (validateStrongRef(maybeRef)) {
7349
+ * // Safe to use as strongRef
7350
+ * record.subject = maybeRef;
7351
+ * }
7352
+ * ```
7353
+ */
7354
+ function validateStrongRef(ref) {
7355
+ if (!ref || typeof ref !== "object") {
7356
+ return false;
5645
7357
  }
7358
+ const obj = ref;
7359
+ return typeof obj.uri === "string" && obj.uri.length > 0 && typeof obj.cid === "string" && obj.cid.length > 0;
5646
7360
  }
5647
7361
  /**
5648
- * Factory function to create an ATProto SDK instance.
7362
+ * Type guard to check if a value is a strongRef.
5649
7363
  *
5650
- * This is a convenience function equivalent to `new ATProtoSDK(config)`.
7364
+ * This is an alias for `validateStrongRef` that provides better semantics
7365
+ * for type narrowing in TypeScript.
5651
7366
  *
5652
- * @param config - SDK configuration
5653
- * @returns A new {@link ATProtoSDK} instance
7367
+ * @param value - The value to check
7368
+ * @returns True if the value is a strongRef, false otherwise
5654
7369
  *
5655
7370
  * @example
5656
7371
  * ```typescript
5657
- * import { createATProtoSDK } from "@hypercerts-org/sdk";
7372
+ * function processReference(ref: unknown) {
7373
+ * if (isStrongRef(ref)) {
7374
+ * // TypeScript knows ref is StrongRef here
7375
+ * console.log(ref.uri);
7376
+ * }
7377
+ * }
7378
+ * ```
7379
+ */
7380
+ function isStrongRef(value) {
7381
+ return validateStrongRef(value);
7382
+ }
7383
+
7384
+ /**
7385
+ * Lexicon Builder Utilities
5658
7386
  *
5659
- * const sdk = createATProtoSDK({
5660
- * oauth: { ... },
5661
- * servers: { pds: "https://bsky.social" },
7387
+ * This module provides utilities for constructing lexicon JSON schemas programmatically.
7388
+ * These builders help developers create valid AT Protocol lexicons with proper structure
7389
+ * and type-safe field definitions.
7390
+ *
7391
+ * @packageDocumentation
7392
+ */
7393
+ /**
7394
+ * Create a string field definition.
7395
+ *
7396
+ * @param options - String field options
7397
+ * @returns A lexicon string field definition
7398
+ *
7399
+ * @example
7400
+ * ```typescript
7401
+ * const titleField = createStringField({
7402
+ * description: "Title of the item",
7403
+ * minLength: 1,
7404
+ * maxLength: 200,
5662
7405
  * });
5663
7406
  * ```
5664
7407
  */
5665
- function createATProtoSDK(config) {
5666
- return new ATProtoSDK(config);
7408
+ function createStringField(options = {}) {
7409
+ return { type: "string", ...options };
7410
+ }
7411
+ /**
7412
+ * Create an integer field definition.
7413
+ *
7414
+ * @param options - Integer field options
7415
+ * @returns A lexicon integer field definition
7416
+ *
7417
+ * @example
7418
+ * ```typescript
7419
+ * const scoreField = createIntegerField({
7420
+ * description: "Score from 0 to 100",
7421
+ * minimum: 0,
7422
+ * maximum: 100,
7423
+ * });
7424
+ * ```
7425
+ */
7426
+ function createIntegerField(options = {}) {
7427
+ return { type: "integer", ...options };
7428
+ }
7429
+ /**
7430
+ * Create a number field definition.
7431
+ *
7432
+ * @param options - Number field options
7433
+ * @returns A lexicon number field definition
7434
+ *
7435
+ * @example
7436
+ * ```typescript
7437
+ * const weightField = createNumberField({
7438
+ * description: "Weight as decimal",
7439
+ * minimum: 0,
7440
+ * maximum: 1,
7441
+ * });
7442
+ * ```
7443
+ */
7444
+ function createNumberField(options = {}) {
7445
+ return { type: "number", ...options };
7446
+ }
7447
+ /**
7448
+ * Create a boolean field definition.
7449
+ *
7450
+ * @param options - Boolean field options
7451
+ * @returns A lexicon boolean field definition
7452
+ *
7453
+ * @example
7454
+ * ```typescript
7455
+ * const verifiedField = createBooleanField({
7456
+ * description: "Whether the item is verified",
7457
+ * default: false,
7458
+ * });
7459
+ * ```
7460
+ */
7461
+ function createBooleanField(options = {}) {
7462
+ return { type: "boolean", ...options };
7463
+ }
7464
+ /**
7465
+ * Create a strongRef field definition.
7466
+ *
7467
+ * StrongRefs are the standard way to reference other records in AT Protocol.
7468
+ * They contain both the AT-URI and CID of the referenced record.
7469
+ *
7470
+ * @param options - Reference field options
7471
+ * @returns A lexicon reference field definition
7472
+ *
7473
+ * @example
7474
+ * ```typescript
7475
+ * const subjectField = createStrongRefField({
7476
+ * description: "The hypercert being evaluated",
7477
+ * });
7478
+ * ```
7479
+ */
7480
+ function createStrongRefField(options = {}) {
7481
+ return {
7482
+ type: "ref",
7483
+ ref: options.ref || "com.atproto.repo.strongRef",
7484
+ description: options.description,
7485
+ };
7486
+ }
7487
+ /**
7488
+ * Create an array field definition.
7489
+ *
7490
+ * @param itemType - The type of items in the array
7491
+ * @param options - Array field options
7492
+ * @returns A lexicon array field definition
7493
+ *
7494
+ * @example
7495
+ * ```typescript
7496
+ * const tagsField = createArrayField(
7497
+ * createStringField({ maxLength: 50 }),
7498
+ * {
7499
+ * description: "List of tags",
7500
+ * minLength: 1,
7501
+ * maxLength: 10,
7502
+ * }
7503
+ * );
7504
+ * ```
7505
+ */
7506
+ function createArrayField(itemType, options = {}) {
7507
+ return {
7508
+ type: "array",
7509
+ items: itemType,
7510
+ ...options,
7511
+ };
7512
+ }
7513
+ /**
7514
+ * Create an object field definition.
7515
+ *
7516
+ * @param options - Object field options
7517
+ * @returns A lexicon object field definition
7518
+ *
7519
+ * @example
7520
+ * ```typescript
7521
+ * const metadataField = createObjectField({
7522
+ * description: "Additional metadata",
7523
+ * properties: {
7524
+ * author: createStringField(),
7525
+ * version: createIntegerField(),
7526
+ * },
7527
+ * required: ["author"],
7528
+ * });
7529
+ * ```
7530
+ */
7531
+ function createObjectField(options = {}) {
7532
+ return { type: "object", ...options };
7533
+ }
7534
+ /**
7535
+ * Create a blob field definition.
7536
+ *
7537
+ * @param options - Blob field options
7538
+ * @returns A lexicon blob field definition
7539
+ *
7540
+ * @example
7541
+ * ```typescript
7542
+ * const imageField = createBlobField({
7543
+ * description: "Profile image",
7544
+ * accept: ["image/png", "image/jpeg"],
7545
+ * maxSize: 1000000, // 1MB
7546
+ * });
7547
+ * ```
7548
+ */
7549
+ function createBlobField(options = {}) {
7550
+ return { type: "blob", ...options };
7551
+ }
7552
+ /**
7553
+ * Create a datetime string field.
7554
+ *
7555
+ * This is a convenience function for creating string fields with datetime format.
7556
+ *
7557
+ * @param options - String field options
7558
+ * @returns A lexicon string field with datetime format
7559
+ *
7560
+ * @example
7561
+ * ```typescript
7562
+ * const createdAtField = createDatetimeField({
7563
+ * description: "When the record was created",
7564
+ * });
7565
+ * ```
7566
+ */
7567
+ function createDatetimeField(options = {}) {
7568
+ return {
7569
+ type: "string",
7570
+ format: "datetime",
7571
+ ...options,
7572
+ };
7573
+ }
7574
+ /**
7575
+ * Create a record definition.
7576
+ *
7577
+ * This defines the structure of records in your lexicon.
7578
+ *
7579
+ * @param properties - The record's properties (fields)
7580
+ * @param required - Array of required field names
7581
+ * @param keyType - The type of record key ("tid" for server-generated, "any" for custom)
7582
+ * @returns A lexicon record definition
7583
+ *
7584
+ * @example
7585
+ * ```typescript
7586
+ * const recordDef = createRecordDef(
7587
+ * {
7588
+ * $type: createStringField({ const: "org.myapp.evaluation" }),
7589
+ * subject: createStrongRefField({ description: "The evaluated item" }),
7590
+ * score: createIntegerField({ minimum: 0, maximum: 100 }),
7591
+ * createdAt: createDatetimeField(),
7592
+ * },
7593
+ * ["$type", "subject", "score", "createdAt"],
7594
+ * "tid"
7595
+ * );
7596
+ * ```
7597
+ */
7598
+ function createRecordDef(properties, required, keyType = "tid") {
7599
+ return {
7600
+ type: "record",
7601
+ key: keyType,
7602
+ record: {
7603
+ type: "object",
7604
+ required,
7605
+ properties,
7606
+ },
7607
+ };
7608
+ }
7609
+ /**
7610
+ * Create a complete lexicon document.
7611
+ *
7612
+ * This creates a full lexicon JSON structure that can be registered with the SDK.
7613
+ *
7614
+ * @param nsid - The NSID (Namespaced Identifier) for this lexicon
7615
+ * @param properties - The record's properties (fields)
7616
+ * @param required - Array of required field names
7617
+ * @param keyType - The type of record key ("tid" for server-generated, "any" for custom)
7618
+ * @returns A complete lexicon document
7619
+ *
7620
+ * @example
7621
+ * ```typescript
7622
+ * const lexicon = createLexiconDoc(
7623
+ * "org.myapp.evaluation",
7624
+ * {
7625
+ * $type: createStringField({ const: "org.myapp.evaluation" }),
7626
+ * subject: createStrongRefField({ description: "The evaluated item" }),
7627
+ * score: createIntegerField({ minimum: 0, maximum: 100 }),
7628
+ * methodology: createStringField({ maxLength: 500 }),
7629
+ * createdAt: createDatetimeField(),
7630
+ * },
7631
+ * ["$type", "subject", "score", "createdAt"],
7632
+ * "tid"
7633
+ * );
7634
+ *
7635
+ * // Register with SDK
7636
+ * sdk.getLexiconRegistry().registerFromJSON(lexicon);
7637
+ * ```
7638
+ */
7639
+ function createLexiconDoc(nsid, properties, required, keyType = "tid") {
7640
+ return {
7641
+ lexicon: 1,
7642
+ id: nsid,
7643
+ defs: {
7644
+ main: createRecordDef(properties, required, keyType),
7645
+ },
7646
+ };
7647
+ }
7648
+ /**
7649
+ * Validate a lexicon document structure.
7650
+ *
7651
+ * Performs basic validation to ensure the lexicon follows AT Protocol conventions.
7652
+ * This does NOT perform full JSON schema validation.
7653
+ *
7654
+ * @param lexicon - The lexicon document to validate
7655
+ * @returns True if valid, false otherwise
7656
+ *
7657
+ * @example
7658
+ * ```typescript
7659
+ * const lexicon = createLexiconDoc(...);
7660
+ * if (validateLexiconStructure(lexicon)) {
7661
+ * sdk.getLexiconRegistry().registerFromJSON(lexicon);
7662
+ * } else {
7663
+ * console.error("Invalid lexicon structure");
7664
+ * }
7665
+ * ```
7666
+ */
7667
+ function validateLexiconStructure(lexicon) {
7668
+ if (!lexicon || typeof lexicon !== "object") {
7669
+ return false;
7670
+ }
7671
+ const doc = lexicon;
7672
+ // Check required top-level fields
7673
+ if (doc.lexicon !== 1)
7674
+ return false;
7675
+ if (typeof doc.id !== "string" || !doc.id)
7676
+ return false;
7677
+ if (!doc.defs || typeof doc.defs !== "object")
7678
+ return false;
7679
+ const defs = doc.defs;
7680
+ if (!defs.main || typeof defs.main !== "object")
7681
+ return false;
7682
+ const main = defs.main;
7683
+ if (main.type !== "record")
7684
+ return false;
7685
+ if (!main.record || typeof main.record !== "object")
7686
+ return false;
7687
+ const record = main.record;
7688
+ if (record.type !== "object")
7689
+ return false;
7690
+ if (!Array.isArray(record.required))
7691
+ return false;
7692
+ if (!record.properties || typeof record.properties !== "object")
7693
+ return false;
7694
+ return true;
7695
+ }
7696
+
7697
+ /**
7698
+ * Sidecar Pattern Utilities
7699
+ *
7700
+ * This module provides utilities for implementing the AT Protocol "sidecar pattern"
7701
+ * where additional records are created that reference a main record via StrongRef.
7702
+ *
7703
+ * ## The Sidecar Pattern
7704
+ *
7705
+ * In AT Protocol, the sidecar pattern uses **unidirectional references**:
7706
+ * - Sidecar records contain a StrongRef (uri + cid) pointing to the main record
7707
+ * - Main records do NOT maintain back-references to sidecars
7708
+ * - Sidecars are discovered by querying records that reference the main record
7709
+ * - Optionally, sidecars can use the same rkey as the main record (in different collections)
7710
+ *
7711
+ * ## Example Use Cases
7712
+ *
7713
+ * - Evaluations that reference hypercerts
7714
+ * - Comments that reference posts
7715
+ * - Metadata records that reference primary entities
7716
+ *
7717
+ * @see https://atproto.com/specs/record-key
7718
+ * @see https://atproto.com/specs/data-model
7719
+ *
7720
+ * @packageDocumentation
7721
+ */
7722
+ /**
7723
+ * Create a sidecar record that references an existing record.
7724
+ *
7725
+ * This is a low-level utility that creates a single sidecar record. The sidecar
7726
+ * record should include a strongRef field that points to the main record.
7727
+ *
7728
+ * @param repo - The repository instance
7729
+ * @param collection - The collection NSID for the sidecar
7730
+ * @param record - The sidecar record data (should include a strongRef to the main record)
7731
+ * @param options - Optional creation options
7732
+ * @returns The created sidecar record
7733
+ *
7734
+ * @example
7735
+ * ```typescript
7736
+ * const hypercert = await repo.hypercerts.create({...});
7737
+ *
7738
+ * const evaluation = await createSidecarRecord(
7739
+ * repo,
7740
+ * "org.myapp.evaluation",
7741
+ * {
7742
+ * $type: "org.myapp.evaluation",
7743
+ * subject: { uri: hypercert.hypercertUri, cid: hypercert.hypercertCid },
7744
+ * score: 85,
7745
+ * createdAt: new Date().toISOString(),
7746
+ * }
7747
+ * );
7748
+ * ```
7749
+ */
7750
+ async function createSidecarRecord(repo, collection, record, options = {}) {
7751
+ return await repo.records.create({
7752
+ collection,
7753
+ record,
7754
+ rkey: options.rkey,
7755
+ });
7756
+ }
7757
+ /**
7758
+ * Attach a sidecar record to an existing main record.
7759
+ *
7760
+ * This creates a new record that references an existing record via strongRef.
7761
+ * It's a higher-level convenience function that wraps `createSidecarRecord`.
7762
+ *
7763
+ * @param repo - The repository instance
7764
+ * @param params - Parameters including the main record reference and sidecar definition
7765
+ * @returns Both the main record reference and the created sidecar
7766
+ *
7767
+ * @example
7768
+ * ```typescript
7769
+ * const hypercert = await repo.hypercerts.create({...});
7770
+ *
7771
+ * const result = await attachSidecar(repo, {
7772
+ * mainRecord: {
7773
+ * uri: hypercert.hypercertUri,
7774
+ * cid: hypercert.hypercertCid,
7775
+ * },
7776
+ * sidecar: {
7777
+ * collection: "org.myapp.evaluation",
7778
+ * record: {
7779
+ * $type: "org.myapp.evaluation",
7780
+ * subject: { uri: hypercert.hypercertUri, cid: hypercert.hypercertCid },
7781
+ * score: 85,
7782
+ * createdAt: new Date().toISOString(),
7783
+ * },
7784
+ * },
7785
+ * });
7786
+ * ```
7787
+ */
7788
+ async function attachSidecar(repo, params) {
7789
+ const sidecarRecord = await createSidecarRecord(repo, params.sidecar.collection, params.sidecar.record, {
7790
+ rkey: params.sidecar.rkey,
7791
+ });
7792
+ return {
7793
+ mainRecord: params.mainRecord,
7794
+ sidecarRecord,
7795
+ };
7796
+ }
7797
+ /**
7798
+ * Create a main record and multiple sidecar records in sequence.
7799
+ *
7800
+ * This orchestrates the creation of a main record followed by one or more
7801
+ * sidecar records that reference it. This is useful for workflows like:
7802
+ * - Creating a project with multiple hypercert claims
7803
+ * - Creating a hypercert with evidence and evaluation records
7804
+ *
7805
+ * @param repo - The repository instance
7806
+ * @param params - Parameters including the main record and sidecar definitions
7807
+ * @returns The main record and all created sidecar records
7808
+ *
7809
+ * @example
7810
+ * ```typescript
7811
+ * const result = await createWithSidecars(repo, {
7812
+ * main: {
7813
+ * collection: "org.hypercerts.project",
7814
+ * record: {
7815
+ * $type: "org.hypercerts.project",
7816
+ * title: "Climate Initiative 2024",
7817
+ * description: "Our climate work",
7818
+ * createdAt: new Date().toISOString(),
7819
+ * },
7820
+ * },
7821
+ * sidecars: [
7822
+ * {
7823
+ * collection: "org.hypercerts.claim.activity",
7824
+ * record: {
7825
+ * $type: "org.hypercerts.claim.activity",
7826
+ * title: "Tree Planting",
7827
+ * // ... other hypercert fields
7828
+ * // Note: If you need to reference the main record, you must wait
7829
+ * // for result.main and then call batchCreateSidecars separately
7830
+ * },
7831
+ * },
7832
+ * {
7833
+ * collection: "org.hypercerts.claim.activity",
7834
+ * record: {
7835
+ * $type: "org.hypercerts.claim.activity",
7836
+ * title: "Carbon Measurement",
7837
+ * // ... other hypercert fields
7838
+ * },
7839
+ * },
7840
+ * ],
7841
+ * });
7842
+ *
7843
+ * console.log(result.main.uri); // Main project record
7844
+ * console.log(result.sidecars.length); // 2 hypercert sidecars
7845
+ * ```
7846
+ */
7847
+ async function createWithSidecars(repo, params) {
7848
+ // Create the main record first
7849
+ const main = await repo.records.create({
7850
+ collection: params.main.collection,
7851
+ record: params.main.record,
7852
+ rkey: params.main.rkey,
7853
+ });
7854
+ // Create all sidecar records
7855
+ const sidecars = [];
7856
+ for (const sidecar of params.sidecars) {
7857
+ const created = await repo.records.create({
7858
+ collection: sidecar.collection,
7859
+ record: sidecar.record,
7860
+ rkey: sidecar.rkey,
7861
+ });
7862
+ sidecars.push(created);
7863
+ }
7864
+ return { main, sidecars };
7865
+ }
7866
+ /**
7867
+ * Batch create multiple sidecar records.
7868
+ *
7869
+ * This is useful when you want to add multiple related records
7870
+ * efficiently. The sidecar records should already contain any necessary
7871
+ * references to the main record in their data.
7872
+ *
7873
+ * @param repo - The repository instance
7874
+ * @param sidecars - Array of sidecar definitions (records should include references)
7875
+ * @returns Array of created sidecar records
7876
+ *
7877
+ * @example
7878
+ * ```typescript
7879
+ * const hypercert = await repo.hypercerts.create({...});
7880
+ * const mainRef = { uri: hypercert.hypercertUri, cid: hypercert.hypercertCid };
7881
+ *
7882
+ * const evaluations = await batchCreateSidecars(repo, [
7883
+ * {
7884
+ * collection: "org.myapp.evaluation",
7885
+ * record: {
7886
+ * $type: "org.myapp.evaluation",
7887
+ * subject: mainRef, // Reference already included
7888
+ * score: 85,
7889
+ * methodology: "Peer review",
7890
+ * createdAt: new Date().toISOString(),
7891
+ * },
7892
+ * },
7893
+ * {
7894
+ * collection: "org.myapp.comment",
7895
+ * record: {
7896
+ * $type: "org.myapp.comment",
7897
+ * subject: mainRef, // Reference already included
7898
+ * text: "Great work!",
7899
+ * createdAt: new Date().toISOString(),
7900
+ * },
7901
+ * },
7902
+ * ]);
7903
+ * ```
7904
+ */
7905
+ async function batchCreateSidecars(repo, sidecars) {
7906
+ const results = [];
7907
+ for (const sidecar of sidecars) {
7908
+ const result = await createSidecarRecord(repo, sidecar.collection, sidecar.record, {
7909
+ rkey: sidecar.rkey,
7910
+ });
7911
+ results.push(result);
7912
+ }
7913
+ return results;
5667
7914
  }
5668
7915
 
5669
7916
  /**
@@ -5825,9 +8072,13 @@ Object.defineProperty(exports, "OrgHypercertsClaimCollection", {
5825
8072
  enumerable: true,
5826
8073
  get: function () { return lexicon.OrgHypercertsClaimCollection; }
5827
8074
  });
5828
- Object.defineProperty(exports, "OrgHypercertsClaimContribution", {
8075
+ Object.defineProperty(exports, "OrgHypercertsClaimContributionDetails", {
8076
+ enumerable: true,
8077
+ get: function () { return lexicon.OrgHypercertsClaimContributionDetails; }
8078
+ });
8079
+ Object.defineProperty(exports, "OrgHypercertsClaimContributorInformation", {
5829
8080
  enumerable: true,
5830
- get: function () { return lexicon.OrgHypercertsClaimContribution; }
8081
+ get: function () { return lexicon.OrgHypercertsClaimContributorInformation; }
5831
8082
  });
5832
8083
  Object.defineProperty(exports, "OrgHypercertsClaimEvaluation", {
5833
8084
  enumerable: true,
@@ -5841,10 +8092,6 @@ Object.defineProperty(exports, "OrgHypercertsClaimMeasurement", {
5841
8092
  enumerable: true,
5842
8093
  get: function () { return lexicon.OrgHypercertsClaimMeasurement; }
5843
8094
  });
5844
- Object.defineProperty(exports, "OrgHypercertsClaimProject", {
5845
- enumerable: true,
5846
- get: function () { return lexicon.OrgHypercertsClaimProject; }
5847
- });
5848
8095
  Object.defineProperty(exports, "OrgHypercertsClaimRights", {
5849
8096
  enumerable: true,
5850
8097
  get: function () { return lexicon.OrgHypercertsClaimRights; }
@@ -5853,6 +8100,10 @@ Object.defineProperty(exports, "OrgHypercertsFundingReceipt", {
5853
8100
  enumerable: true,
5854
8101
  get: function () { return lexicon.OrgHypercertsFundingReceipt; }
5855
8102
  });
8103
+ Object.defineProperty(exports, "OrgHypercertsHelperWorkScopeTag", {
8104
+ enumerable: true,
8105
+ get: function () { return lexicon.OrgHypercertsHelperWorkScopeTag; }
8106
+ });
5856
8107
  Object.defineProperty(exports, "validate", {
5857
8108
  enumerable: true,
5858
8109
  get: function () { return lexicon.validate; }
@@ -5865,6 +8116,7 @@ exports.AccountActionSchema = AccountActionSchema;
5865
8116
  exports.AccountAttrSchema = AccountAttrSchema;
5866
8117
  exports.AccountPermissionSchema = AccountPermissionSchema;
5867
8118
  exports.AuthenticationError = AuthenticationError;
8119
+ exports.BaseOperations = BaseOperations;
5868
8120
  exports.BlobPermissionSchema = BlobPermissionSchema;
5869
8121
  exports.CollaboratorPermissionsSchema = CollaboratorPermissionsSchema;
5870
8122
  exports.CollaboratorSchema = CollaboratorSchema;
@@ -5876,6 +8128,7 @@ exports.IdentityPermissionSchema = IdentityPermissionSchema;
5876
8128
  exports.InMemorySessionStore = InMemorySessionStore;
5877
8129
  exports.InMemoryStateStore = InMemoryStateStore;
5878
8130
  exports.IncludePermissionSchema = IncludePermissionSchema;
8131
+ exports.LexiconRegistry = LexiconRegistry;
5879
8132
  exports.MimeTypeSchema = MimeTypeSchema;
5880
8133
  exports.NetworkError = NetworkError;
5881
8134
  exports.NsidSchema = NsidSchema;
@@ -5895,13 +8148,37 @@ exports.TRANSITION_SCOPES = TRANSITION_SCOPES;
5895
8148
  exports.TimeoutConfigSchema = TimeoutConfigSchema;
5896
8149
  exports.TransitionScopeSchema = TransitionScopeSchema;
5897
8150
  exports.ValidationError = ValidationError;
8151
+ exports.attachSidecar = attachSidecar;
8152
+ exports.batchCreateSidecars = batchCreateSidecars;
8153
+ exports.buildAtUri = buildAtUri;
5898
8154
  exports.buildScope = buildScope;
5899
8155
  exports.createATProtoSDK = createATProtoSDK;
8156
+ exports.createArrayField = createArrayField;
8157
+ exports.createBlobField = createBlobField;
8158
+ exports.createBooleanField = createBooleanField;
8159
+ exports.createDatetimeField = createDatetimeField;
8160
+ exports.createIntegerField = createIntegerField;
8161
+ exports.createLexiconDoc = createLexiconDoc;
8162
+ exports.createNumberField = createNumberField;
8163
+ exports.createObjectField = createObjectField;
8164
+ exports.createRecordDef = createRecordDef;
8165
+ exports.createSidecarRecord = createSidecarRecord;
8166
+ exports.createStringField = createStringField;
8167
+ exports.createStrongRef = createStrongRef;
8168
+ exports.createStrongRefField = createStrongRefField;
8169
+ exports.createStrongRefFromResult = createStrongRefFromResult;
8170
+ exports.createWithSidecars = createWithSidecars;
8171
+ exports.extractRkeyFromUri = extractRkeyFromUri;
5900
8172
  exports.hasAllPermissions = hasAllPermissions;
5901
8173
  exports.hasAnyPermission = hasAnyPermission;
5902
8174
  exports.hasPermission = hasPermission;
8175
+ exports.isStrongRef = isStrongRef;
8176
+ exports.isValidAtUri = isValidAtUri;
5903
8177
  exports.mergeScopes = mergeScopes;
8178
+ exports.parseAtUri = parseAtUri;
5904
8179
  exports.parseScope = parseScope;
5905
8180
  exports.removePermissions = removePermissions;
8181
+ exports.validateLexiconStructure = validateLexiconStructure;
5906
8182
  exports.validateScope = validateScope;
8183
+ exports.validateStrongRef = validateStrongRef;
5907
8184
  //# sourceMappingURL=index.cjs.map