@feltdb/core 0.12.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/build-identity.json +4 -4
  2. package/dist/cli/commands.js +8 -2
  3. package/dist/cli/workspace-integration.js +8 -0
  4. package/dist/create/build-identity.json +4 -4
  5. package/dist/create/cli.js +46 -37
  6. package/dist/create/create.js +17 -2
  7. package/dist/create/package-versions.js +1 -1
  8. package/dist/create/server-source/Cargo.lock +4 -4
  9. package/dist/create/server-source/Cargo.toml +1 -1
  10. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +204 -1
  11. package/dist/create/server-source/crates/feltdb/src/workload.rs +685 -53
  12. package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +18 -12
  13. package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +17 -5
  14. package/dist/create/server-source/crates/feltdb-client/Cargo.toml +1 -1
  15. package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -1
  16. package/dist/create/server-source/crates/feltdb-server/src/agent_execution.rs +511 -0
  17. package/dist/create/server-source/crates/feltdb-server/src/auth.rs +28 -0
  18. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +1 -0
  19. package/dist/create/server-source/crates/feltdb-server/src/main.rs +844 -135
  20. package/dist/create/setup-summary.js +40 -0
  21. package/dist/db.d.ts +27 -1
  22. package/dist/db.d.ts.map +1 -1
  23. package/dist/db.js +17 -4
  24. package/dist/development-runtime-bridge.d.ts +20 -0
  25. package/dist/development-runtime-bridge.d.ts.map +1 -1
  26. package/dist/development-runtime-bridge.js +61 -12
  27. package/dist/http-db.d.ts +13 -0
  28. package/dist/http-db.d.ts.map +1 -1
  29. package/dist/http-db.js +33 -4
  30. package/dist/studio/{KeyManagementPanel-D5FMvvU9.js → KeyManagementPanel-skUNnKRw.js} +36 -29
  31. package/dist/studio/app.d.ts +8 -1
  32. package/dist/studio/app.d.ts.map +1 -1
  33. package/dist/studio/components/KeyManagementPanel.d.ts.map +1 -1
  34. package/dist/studio/components/KeyManagementPanel.js +1 -1
  35. package/dist/studio/components/index.js +2 -2
  36. package/dist/studio/{components-B4eKIUN5.js → components-BSGmHfif.js} +6 -6
  37. package/dist/studio/control-plane/Data.d.ts.map +1 -1
  38. package/dist/studio/control-plane/Instance.d.ts +4 -1
  39. package/dist/studio/control-plane/Instance.d.ts.map +1 -1
  40. package/dist/studio/control-plane/Operations.d.ts +4 -1
  41. package/dist/studio/control-plane/Operations.d.ts.map +1 -1
  42. package/dist/studio/control-plane/index.js +2 -2
  43. package/dist/studio/control-plane/primitives.d.ts +16 -0
  44. package/dist/studio/control-plane/primitives.d.ts.map +1 -1
  45. package/dist/studio/{control-plane-fxMcJn74.js → control-plane-CaBA7Rgv.js} +448 -424
  46. package/dist/studio/index.d.ts +1 -0
  47. package/dist/studio/index.d.ts.map +1 -1
  48. package/dist/studio/index.js +384 -247
  49. package/dist/studio/navigation.d.ts +57 -0
  50. package/dist/studio/navigation.d.ts.map +1 -0
  51. package/dist/studio/studio.css +1 -1
  52. package/dist/studio/utils/control-plane-api.d.ts +2 -0
  53. package/dist/studio/utils/control-plane-api.d.ts.map +1 -1
  54. package/dist/studio/utils/control-plane-api.js +5 -1
  55. package/dist/studio-app/assets/{feltdb_wasm-BatwwogY.js → feltdb_wasm-3hDngl1h.js} +1 -1
  56. package/dist/studio-app/assets/feltdb_wasm_bg-CFQT4gCy.wasm +0 -0
  57. package/dist/studio-app/assets/index-Dql1hS5m.js +30 -0
  58. package/dist/studio-app/assets/{index-CiC8VY-a.css → index-vi21oEQa.css} +1 -1
  59. package/dist/studio-app/index.html +2 -2
  60. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  61. package/dist/wasm/package.json +1 -1
  62. package/dist/workspace/local-development-authority.d.ts +19 -0
  63. package/dist/workspace/local-development-authority.d.ts.map +1 -1
  64. package/dist/workspace/local-development-authority.js +335 -15
  65. package/package.json +1 -1
  66. package/dist/studio-app/assets/feltdb_wasm_bg-BX0newOr.wasm +0 -0
  67. package/dist/studio-app/assets/index-DoVvbIay.js +0 -30
@@ -1,19 +1,91 @@
1
1
  import http from 'node:http';
2
+ import { createHash, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
2
3
  import path from 'node:path';
3
4
  import { FileJsDb } from '../file-db.js';
4
5
  import { FELTDB_CONVERGENCE_COLLECTION, FeltDBProposalService } from '../proposal.js';
5
6
  import { IntentService } from '../intent.js';
6
- const DEVELOPMENT_CONTROL_PLANE_CAPABILITIES = {
7
+ /**
8
+ * Why the development authority does not serve a part of the control plane.
9
+ *
10
+ * `feltdb dev` runs a lightweight authority that holds application state and
11
+ * the application contract. Everything else the control plane names belongs to
12
+ * a deployed feltdb-server. Each capability is declared — available or not —
13
+ * so Studio can tell "not part of this instance" from "unknown to Studio", and
14
+ * the reason says where the feature does live.
15
+ */
16
+ const SERVER_ONLY = {
17
+ data: 'Record inspection, writes, sorting and index management through the control plane are served by feltdb-server. In development, the application writes its own state, and Data shows it.',
18
+ state: 'Semantic history, diff, conflict classification and reconciliation are served by feltdb-server; the development authority keeps current state only.',
19
+ operations: 'Operation history, rejections, transaction status and audit are recorded by feltdb-server; the development authority does not record them.',
20
+ agents: 'Agent principals, sessions, authorization decisions and Durable Work run on feltdb-server. The development authority does not run agents; the agents your application declares are listed under Build › Agents.',
21
+ replication: 'Replication health is reported by a feltdb-server cluster; the development authority is a single local node.',
22
+ applications: 'Managing applications is part of a deployed feltdb-server; a development session serves exactly one application.',
23
+ monitoring: 'Instance metrics come from feltdb-server; the development authority does not export them.',
24
+ settings: 'Instance settings belong to a deployed feltdb-server; a development session is configured by feltdb.config.json.',
25
+ developer: 'Developer access is issued by feltdb-server; the development authority is unauthenticated and loopback-only.',
26
+ };
27
+ const SERVED = {
28
+ 'overview.instance': { area: 'overview', title: 'Development authority overview', surface: ['GET /v1/control-plane/overview'] },
29
+ 'data.collections.list': { area: 'data', title: 'List declared collections', surface: ['GET /v1/application/schema'] },
30
+ 'data.schema.inspect': { area: 'data', title: 'Inspect the application schema', surface: ['GET /v1/application/schema'] },
31
+ 'data.records.browse': { area: 'data', title: 'Browse records', surface: ['GET /v1/data/{collection}'] },
32
+ };
33
+ /**
34
+ * Every capability the feltdb-server control plane declares, in its order,
35
+ * with the area it belongs to. Held equal to `control_plane.rs` by
36
+ * `development-authority-capabilities.test.mjs`.
37
+ */
38
+ const CONTROL_PLANE_IDS = [
39
+ ['overview.instance', 'overview', 'Instance overview'],
40
+ ['data.collections.list', 'data', 'List collections'],
41
+ ['data.records.browse', 'data', 'Browse records'],
42
+ ['data.records.inspect', 'data', 'Inspect a record'],
43
+ ['data.records.create', 'data', 'Create records'],
44
+ ['data.records.update', 'data', 'Update records'],
45
+ ['data.records.delete', 'data', 'Delete records'],
46
+ ['data.records.sort', 'data', 'Sort records'],
47
+ ['data.schema.inspect', 'data', 'Inspect the schema'],
48
+ ['data.indexes.inspect', 'data', 'Inspect indexes'],
49
+ ['data.indexes.rebuild', 'data', 'Rebuild indexes'],
50
+ ['data.records.history', 'state', 'Record history'],
51
+ ['state.resources.list', 'state', 'List state resources'],
52
+ ['state.history', 'state', 'State history'],
53
+ ['state.diff.semantic', 'state', 'Semantic diff'],
54
+ ['state.conflict.classify', 'state', 'Classify conflicts'],
55
+ ['state.reconciliation.plan', 'state', 'Plan reconciliation'],
56
+ ['state.reconciliation.execute', 'state', 'Execute reconciliation'],
57
+ ['state.branch.heads', 'state', 'Branch heads'],
58
+ ['provenance.record', 'state', 'Record provenance'],
59
+ ['provenance.transactions', 'operations', 'Recent attributed transactions'],
60
+ ['agents.list', 'agents', 'List agents'],
61
+ ['agents.identity', 'agents', 'Agent identity'],
62
+ ['agents.register', 'agents', 'Register agents'],
63
+ ['agents.work', 'agents', 'Agent work (Durable Work)'],
64
+ ['agents.work.review_gate', 'agents', 'Agent work review gate'],
65
+ ['agents.sessions', 'agents', 'Agent sessions'],
66
+ ['agents.authorization_decisions', 'agents', 'Agent authorization decisions'],
67
+ ['agents.task_context', 'agents', 'Agent task context'],
68
+ ['operations.recent', 'operations', 'Recent operations'],
69
+ ['operations.rejections', 'operations', 'Rejected operations'],
70
+ ['operations.events', 'operations', 'Operation events'],
71
+ ['operations.transaction_status', 'operations', 'Transaction status'],
72
+ ['operations.correlation_id', 'operations', 'Correlation ids'],
73
+ ['operations.audit_log', 'operations', 'Audit log'],
74
+ ['replication.health', 'replication', 'Replication health'],
75
+ ['applications.list', 'applications', 'List applications'],
76
+ ['monitoring.metrics', 'monitoring', 'Instance metrics'],
77
+ ['monitoring.latency', 'monitoring', 'Latency'],
78
+ ['settings.instance', 'settings', 'Instance settings'],
79
+ ['settings.mutate', 'settings', 'Change settings'],
80
+ ['developer.tool_projection', 'developer', 'Tool projection'],
81
+ ['developer.access', 'developer', 'Developer access'],
82
+ ];
83
+ export const DEVELOPMENT_CONTROL_PLANE_CAPABILITIES = {
7
84
  contract: 'feltdb.control-plane.capabilities',
8
85
  version: 1,
9
- capabilities: [
10
- { id: 'overview.instance', area: 'overview', title: 'Development authority overview', status: 'available', surface: ['GET /v1/control-plane/overview'], note: '' },
11
- { id: 'data.collections.list', area: 'data', title: 'List declared collections', status: 'available', surface: ['GET /v1/application/schema'], note: '' },
12
- { id: 'data.schema.inspect', area: 'data', title: 'Inspect the application schema', status: 'available', surface: ['GET /v1/application/schema'], note: '' },
13
- { id: 'data.records.browse', area: 'data', title: 'Browse records', status: 'available', surface: ['GET /v1/data/{collection}'], note: '' },
14
- { id: 'provenance.transactions', area: 'operations', title: 'Recent attributed transactions', status: 'unsupported', surface: [], note: 'Transaction observability is not available from the lightweight development authority.' },
15
- { id: 'replication.health', area: 'replication', title: 'Replication health', status: 'unsupported', surface: [], note: 'Replication health is not available from the lightweight development authority.' },
16
- ],
86
+ capabilities: CONTROL_PLANE_IDS.map(([id, area, title]) => SERVED[id]
87
+ ? { id, ...SERVED[id], status: 'available', note: '' }
88
+ : { id, area, title, status: 'unsupported', surface: [], note: SERVER_ONLY[area] }),
17
89
  };
18
90
  /** Start the durable, unauthenticated loopback authority used by `feltdb dev`. */
19
91
  export async function startLocalDevelopmentAuthority(options) {
@@ -37,9 +109,26 @@ export async function startLocalDevelopmentAuthority(options) {
37
109
  const proposalService = options.proposalContext ? new FeltDBProposalService(store, options.proposalContext, proposalAuthorityToken) : undefined;
38
110
  const intentService = proposalService && options.proposalContext ? new IntentService(store, options.proposalContext, proposalService, options.intentObservations) : undefined;
39
111
  const streams = new Set();
112
+ const notify = (collection) => {
113
+ // `resource` is the field every client reads (the event contract
114
+ // feltdb-server serves); `change` is kept for readers of the older frame.
115
+ const frame = `data: ${JSON.stringify({ resource: collection, change: { capability: collection } })}\n\n`;
116
+ for (const stream of streams)
117
+ stream.write(frame);
118
+ };
40
119
  let endpoint = '';
41
120
  const server = http.createServer(async (request, response) => {
42
121
  try {
122
+ // The authority is unauthenticated, so which pages may reach it is the
123
+ // whole boundary: only a loopback origin (the developer's own application
124
+ // and Studio) and a loopback Host (not a rebound DNS name). A request
125
+ // without an Origin is not a browser page and is not restricted here.
126
+ if (!loopbackHost(request.headers.host) || (request.headers.origin !== undefined && !loopbackOrigin(request.headers.origin))) {
127
+ response.statusCode = 403;
128
+ response.setHeader('Content-Type', 'application/json');
129
+ response.end(JSON.stringify({ error: 'NON_LOOPBACK_ORIGIN', message: 'The development authority serves loopback origins only.' }));
130
+ return;
131
+ }
43
132
  response.setHeader('Access-Control-Allow-Origin', '*');
44
133
  response.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS');
45
134
  response.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, FeltDB-Protocol, FeltDB-Proposal-Authority');
@@ -349,14 +438,106 @@ export async function startLocalDevelopmentAuthority(options) {
349
438
  json(response, 405, { error: 'METHOD_NOT_ALLOWED' });
350
439
  return;
351
440
  }
441
+ const authMatch = pathname.match(/^\/v1\/auth\/(signup|signin|signout|session)$/);
442
+ if (authMatch) {
443
+ const operation = authMatch[1];
444
+ const expectedMethod = operation === 'session' ? 'GET' : 'POST';
445
+ if (request.method !== expectedMethod) {
446
+ json(response, 405, { error: 'METHOD_NOT_ALLOWED' });
447
+ return;
448
+ }
449
+ const body = operation === 'signup' || operation === 'signin' ? parseObject(await readBody(request)) : {};
450
+ const bearer = String(request.headers.authorization || '').replace(/^Bearer\s+/i, '');
451
+ const outcome = developmentAuth(db, operation, body, bearer);
452
+ json(response, outcome.status, outcome.body);
453
+ return;
454
+ }
455
+ // The direct data plane `HttpJsDb` speaks to any authority, so an
456
+ // application connected to this one behaves as it would against
457
+ // feltdb-server: same routes, same shapes, same refusals.
458
+ if (request.method === 'GET' && pathname === '/revision') {
459
+ json(response, 200, { revision: db.get_sequence(), scope: db.instance_id() });
460
+ return;
461
+ }
462
+ if (request.method === 'POST' && pathname === '/query') {
463
+ const query = parseObject(await readBody(request));
464
+ if (namesPrivateCollection([query.collection])) {
465
+ refusePrivate(response);
466
+ return;
467
+ }
468
+ try {
469
+ json(response, 200, await db.authority_query(query));
470
+ }
471
+ catch (error) {
472
+ const code = error.code;
473
+ json(response, 400, { code, error: errorMessage(error), message: errorMessage(error) });
474
+ }
475
+ return;
476
+ }
477
+ if (request.method === 'POST' && pathname === '/transactions/read-basis') {
478
+ const reads = parseObject(await readBody(request));
479
+ if (namesPrivateCollection(readSetCollections(reads))) {
480
+ refusePrivate(response);
481
+ return;
482
+ }
483
+ json(response, 200, await db.read_basis(reads));
484
+ return;
485
+ }
486
+ if (request.method === 'POST' && pathname === '/transactions') {
487
+ const transaction = parseObject(await readBody(request));
488
+ if (namesPrivateCollection([
489
+ ...(transaction.operations ?? []).map((operation) => operation?.collection),
490
+ ...(transaction.preconditions ?? []).map((condition) => condition?.collection),
491
+ ...(transaction.fences ?? []).flatMap((fence) => readSetCollections(fence?.reads)),
492
+ ])) {
493
+ refusePrivate(response);
494
+ return;
495
+ }
496
+ let result;
497
+ try {
498
+ result = await db.commit_transaction(transaction);
499
+ }
500
+ catch (error) {
501
+ json(response, transactionRefusalStatus(error), transactionRefusal(error));
502
+ return;
503
+ }
504
+ json(response, 200, { ...result, status: 'committed' });
505
+ if (!result.duplicate) {
506
+ const collections = new Set((transaction.operations ?? []).map((operation) => operation.collection));
507
+ for (const collection of collections)
508
+ notify(collection);
509
+ }
510
+ return;
511
+ }
512
+ const casMatch = pathname.match(/^\/collections\/([^/]+)\/([^/]+)\/cas$/);
513
+ if (request.method === 'POST' && casMatch) {
514
+ const collection = decodeURIComponent(casMatch[1]);
515
+ if (namesPrivateCollection([collection])) {
516
+ refusePrivate(response);
517
+ return;
518
+ }
519
+ const input = parseObject(await readBody(request));
520
+ const result = await db.cas({
521
+ key: `${collection}:${decodeURIComponent(casMatch[2])}`,
522
+ expectedVersion: Number(input.expectedVersion),
523
+ expectedEpoch: input.expectedEpoch,
524
+ expectedLeaseId: input.expectedLeaseId,
525
+ rejectIfDiverged: input.rejectIfDiverged,
526
+ value: JSON.stringify(input.value ?? {}),
527
+ });
528
+ json(response, 200, result);
529
+ if (result.updated)
530
+ notify(collection);
531
+ return;
532
+ }
352
533
  const match = pathname.match(/^\/collections\/([^/]+)(?:\/([^/]+))?$/);
353
534
  if (!match) {
354
535
  json(response, 404, { error: 'NOT_FOUND' });
355
536
  return;
356
537
  }
357
538
  const collection = decodeURIComponent(match[1]);
358
- if (collection.startsWith('_feltdb.')) {
359
- json(response, 403, { error: 'SYSTEM_COLLECTION_REQUIRES_SERVICE_API' });
539
+ if (namesPrivateCollection([collection])) {
540
+ refusePrivate(response);
360
541
  return;
361
542
  }
362
543
  const id = match[2] ? decodeURIComponent(match[2]) : undefined;
@@ -406,9 +587,7 @@ export async function startLocalDevelopmentAuthority(options) {
406
587
  return;
407
588
  }
408
589
  json(response, request.method === 'POST' ? 201 : 200, { ok: true });
409
- const frame = `data: ${JSON.stringify({ change: { capability: collection } })}\n\n`;
410
- for (const stream of streams)
411
- stream.write(frame);
590
+ notify(collection);
412
591
  }
413
592
  catch (error) {
414
593
  if (!response.headersSent)
@@ -440,11 +619,152 @@ function json(response, status, value) {
440
619
  response.setHeader('Content-Type', 'application/json; charset=utf-8');
441
620
  response.end(JSON.stringify(value));
442
621
  }
622
+ const IDENTITIES = '_feltdb.development_identities';
623
+ const SESSIONS = '_feltdb.development_sessions';
624
+ const SESSION_TTL_MS = 15 * 60_000;
625
+ function developmentAuth(db, operation, body, bearer) {
626
+ const read = (key) => {
627
+ const result = db.get(key);
628
+ if (!result.success)
629
+ throw new Error(result.error);
630
+ return result.data ? JSON.parse(result.data) : undefined;
631
+ };
632
+ const tokenHash = (token) => createHash('sha256').update(token).digest('hex');
633
+ const passwordHash = (password, salt) => scryptSync(password, salt, 32).toString('hex');
634
+ const refuse = (status, code, message) => ({ status, body: { code, error: code, message } });
635
+ const issue = (identity) => {
636
+ const token = randomBytes(32).toString('hex');
637
+ const session = { session_id: `session_${randomBytes(12).toString('hex')}`, expires_at: Date.now() + SESSION_TTL_MS };
638
+ const stored = db.insert(`${SESSIONS}:${tokenHash(token)}`, JSON.stringify({ ...session, user_id: identity.id }));
639
+ if (!stored.success)
640
+ throw new Error(stored.error);
641
+ return { status: 200, body: { access_token: token, actor: { user_id: identity.id, email: identity.email, display_name: identity.display_name }, session } };
642
+ };
643
+ if (operation === 'signout') {
644
+ if (bearer)
645
+ db.delete(`${SESSIONS}:${tokenHash(bearer)}`);
646
+ return { status: 200, body: {} };
647
+ }
648
+ if (operation === 'session') {
649
+ const sessionKey = `${SESSIONS}:${tokenHash(bearer)}`;
650
+ const session = bearer ? read(sessionKey) : undefined;
651
+ // An expired session is dead; remove it rather than keep it forever.
652
+ if (session && session.expires_at <= Date.now())
653
+ db.delete(sessionKey);
654
+ const identity = session && session.expires_at > Date.now()
655
+ ? read(`${IDENTITIES}:${session.user_id}`)
656
+ : undefined;
657
+ if (!session || !identity)
658
+ return refuse(401, 'AUTHENTICATION_REQUIRED', 'Authentication required');
659
+ return { status: 200, body: { access_token: bearer, actor: { user_id: identity.id, email: identity.email, display_name: identity.display_name }, session: { session_id: session.session_id, expires_at: session.expires_at } } };
660
+ }
661
+ const email = String(body.email ?? '').trim().toLowerCase();
662
+ const password = String(body.password ?? '');
663
+ if (!email || password.length < 8)
664
+ return refuse(422, 'INVALID_CREDENTIALS_FORMAT', 'email and a password of at least 8 characters are required');
665
+ const byEmail = `${IDENTITIES}:email:${createHash('sha256').update(email).digest('hex')}`;
666
+ const existingId = read(byEmail)?.id;
667
+ if (operation === 'signup') {
668
+ if (existingId)
669
+ return refuse(409, 'CONFLICT', 'identity already exists');
670
+ const salt = randomBytes(16).toString('hex');
671
+ const identity = { id: `user_${randomBytes(12).toString('hex')}`, email, display_name: typeof body.display_name === 'string' ? body.display_name : undefined, salt, password_hash: passwordHash(password, salt), created_at: new Date().toISOString() };
672
+ const claimed = db.insert(byEmail, JSON.stringify({ id: identity.id }));
673
+ if (!claimed.success)
674
+ return refuse(409, 'CONFLICT', 'identity already exists');
675
+ const stored = db.insert(`${IDENTITIES}:${identity.id}`, JSON.stringify(identity));
676
+ if (!stored.success) {
677
+ // Do not leave the email claimed by an identity that was never stored.
678
+ db.delete(byEmail);
679
+ throw new Error(stored.error);
680
+ }
681
+ return issue(identity);
682
+ }
683
+ const identity = existingId ? read(`${IDENTITIES}:${existingId}`) : undefined;
684
+ const presented = Buffer.from(passwordHash(password, identity?.salt ?? 'no-identity'), 'hex');
685
+ const expected = Buffer.from(identity?.password_hash ?? '0'.repeat(64), 'hex');
686
+ if (!identity || presented.length !== expected.length || !timingSafeEqual(presented, expected)) {
687
+ return refuse(401, 'INVALID_CREDENTIALS', 'invalid credentials');
688
+ }
689
+ return issue(identity);
690
+ }
691
+ /**
692
+ * `_feltdb.*` collections are the authority's own records — development
693
+ * identities and sessions among them — and no client route reads or writes
694
+ * them: not `/collections`, not a query, not a transaction or its fences.
695
+ */
696
+ function namesPrivateCollection(collections) {
697
+ // Coerced the way the transaction and query engines coerce a collection
698
+ // name, so a value that is not a string (`["_feltdb.x"]` stringifies to the
699
+ // private name) is judged by what it will be read as, not by its type.
700
+ return collections.some(collection => collection !== undefined && collection !== null
701
+ && String(collection).trim().toLowerCase().startsWith('_feltdb.'));
702
+ }
703
+ function loopbackHost(host) {
704
+ if (!host)
705
+ return true;
706
+ try {
707
+ return LOOPBACK_HOSTNAMES.has(new URL(`http://${host}`).hostname);
708
+ }
709
+ catch {
710
+ return false;
711
+ }
712
+ }
713
+ function loopbackOrigin(origin) {
714
+ if (!origin)
715
+ return false;
716
+ try {
717
+ return LOOPBACK_HOSTNAMES.has(new URL(origin).hostname);
718
+ }
719
+ catch {
720
+ return false;
721
+ }
722
+ }
723
+ const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
724
+ function readSetCollections(reads) {
725
+ const set = (reads ?? {});
726
+ return [...(set.records ?? []), ...(set.predicates ?? [])].map(entry => entry?.collection);
727
+ }
728
+ function refusePrivate(response) {
729
+ json(response, 403, { error: 'SYSTEM_COLLECTION_REQUIRES_SERVICE_API' });
730
+ }
731
+ function errorMessage(error) {
732
+ return error instanceof Error ? error.message : String(error);
733
+ }
734
+ /**
735
+ * A refused transaction, in the document feltdb-server answers with: a lost
736
+ * conditional race is `409 PRECONDITION_FAILED` with the failed predicate, a
737
+ * reused transaction id is `409` with its idempotency code, and a malformed
738
+ * request is `400`. Callers type the refusal from `code`, never from prose.
739
+ */
740
+ function transactionRefusal(error) {
741
+ const refusal = error;
742
+ return {
743
+ error: errorMessage(error),
744
+ code: refusal.feltdbCode ?? refusal.code,
745
+ ...(refusal.feltdbConflict ? { failure: refusal.feltdbConflict } : {}),
746
+ };
747
+ }
748
+ function transactionRefusalStatus(error) {
749
+ const refusal = error;
750
+ if (refusal.feltdbCode === 'PRECONDITION_FAILED')
751
+ return 409;
752
+ if (refusal.code === 'IDEMPOTENCY_CONFLICT' || refusal.code === 'IDEMPOTENCY_UNVERIFIABLE')
753
+ return 409;
754
+ return 400;
755
+ }
756
+ const MAX_BODY_BYTES = 1024 * 1024;
443
757
  function readBody(request) {
444
758
  return new Promise((resolve, reject) => {
445
759
  let body = '';
446
760
  request.setEncoding('utf8');
447
- request.on('data', chunk => { body += chunk; });
761
+ request.on('data', chunk => {
762
+ body += chunk;
763
+ if (body.length > MAX_BODY_BYTES) {
764
+ request.destroy();
765
+ reject(new Error('Request body is too large'));
766
+ }
767
+ });
448
768
  request.once('end', () => resolve(body));
449
769
  request.once('error', reject);
450
770
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feltdb/core",
3
- "version": "0.12.2",
3
+ "version": "0.13.0",
4
4
  "description": "FeltDB Core - Application-facing database with Browser, Local, and Server runtimes. Durable state, reactive APIs.",
5
5
  "license": "MIT",
6
6
  "type": "module",