@volter/teams 0.2.54 → 0.2.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/teams",
3
- "version": "0.2.54",
3
+ "version": "0.2.56",
4
4
  "type": "module",
5
5
  "description": "Volter Teams, the neutral team plane: machine identity and daemon core, the local door and server link, admission from machine grants, product doors, and the Teams server's neutral routes (company RFC 0008).",
6
6
  "license": "MIT",
@@ -12,6 +12,16 @@ const stmt = (sql, ...params) => ({sql, params});
12
12
  const batches = (list, size = 90) => Array.from({length: Math.ceil(list.length / size)}, (_, at) => list.slice(at * size, (at + 1) * size));
13
13
  const holes = list => list.map(() => '?').join(',');
14
14
  const EVENT_REPLAY_BATCH = 32;
15
+ // A publication is written about four times: the event, its resource's snapshot, its idempotent answer, and a
16
+ // webhook delivery. Past the host's maximum a write does not fail, it resets the server under every machine's link:
17
+ // an event that would not fit under the limit beside a mebibyte kept for retention is refused, and writes nothing.
18
+ const EVENT_COPIES = 4, EVENT_RESERVE = 1024 * 1024;
19
+ async function eventHeadroomSpent(api, res, bytes) {
20
+ const usage = await api.storageUsage?.();
21
+ if (!usage || usage.used + bytes * EVENT_COPIES + EVENT_RESERVE < usage.limit) return false;
22
+ res._auditRecord = null; res._idempotencyRecord = null;
23
+ return true;
24
+ }
15
25
  const fail = (status,code,message) => {throw Object.assign(new Error(message),{status,code});};
16
26
  const text = (value,label) => {if(typeof value!=='string'||!value||value.length>256||/[\x00-\x1f]/.test(value))fail(400,'invalid_request',`${label} must be a nonempty string of at most 256 characters`);return value;};
17
27
  const eventOf = row => ({id:row.id,org:row.team_id,at:row.created_at,actor:JSON.parse(row.actor_json),kind:row.kind,resource:{kind:row.resource_kind,id:row.resource_id},data:JSON.parse(row.data_json)});
@@ -93,8 +103,10 @@ export async function publishedEventRoute(ctx) {
93
103
  if(body.actor!=null){exact(body.actor,['session']);if(body.actor.session!=null){text(body.actor.session,'actor.session');if(!body.actor.session.startsWith('session:'))fail(400,'invalid_request','actor.session must name a session');actor.session=body.actor.session;}}
94
104
  const source=await api.store.one(OWN_SOURCE,[auth.team_id,publication[1],auth.principal_id,sourceMachine(auth)]);
95
105
  if(!source)fail(404,'not_found','event source not found');
106
+ const spent=await eventHeadroomSpent(api,res,Buffer.byteLength(JSON.stringify(body.data)));
96
107
  const old=await api.store.one('SELECT * FROM org_events WHERE id=?',[body.id]);
97
108
  if(old){if(old.team_id!==auth.team_id||old.source_id!==publication[1]||old.kind!==body.kind||old.resource_id!==body.resource.id||old.source_revision!==body.revision||canonicalJson(JSON.parse(old.data_json))!==canonicalJson(body.data)||canonicalJson(JSON.parse(old.actor_json))!==canonicalJson(actor))fail(409,'idempotency_conflict','event id already names another event');send(res,200,result(api,auth,eventOf(old)),requestId);return true;}
109
+ if(spent){res.setHeader('Retry-After','60');throw Object.assign(new Error('event deferred while Teams storage is full'),{status:507,code:'storage_full',retryable:true});}
98
110
  const now=api.clock.nowMs(),guards=[...authorityGuards(auth,now),{sql:OWN_SOURCE,params:[auth.team_id,publication[1],auth.principal_id,sourceMachine(auth)],status:403,code:'forbidden',message:'event source authority changed'},
99
111
  {sql:'SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM event_resources WHERE team_id=? AND source_id=? AND resource_kind=? AND resource_id=? AND source_revision>=?)',params:[auth.team_id,publication[1],body.resource.kind,body.resource.id,body.revision],status:409,code:'revision_mismatch',message:'resource publication is older than its current snapshot'}];
100
112
  const event={id:body.id,org:auth.team_id,at:now,actor,kind:body.kind,resource:body.resource,data:body.data},response=result(api,auth,event);
package/server/wire.mjs CHANGED
@@ -4,7 +4,7 @@ import { createHash, randomBytes } from 'node:crypto';
4
4
 
5
5
  export class ContractError extends Error { constructor(message, details = null) { super(message); this.name = 'ContractError'; this.code = 'invalid_request'; this.details = details; } }
6
6
  export function canonicalJson(value) { if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`; return JSON.stringify(value); }
7
- export function validationFailure(error, requestId) { return { version:1, error:{ code:error?.code ?? 'invalid_request', message:error?.message ?? 'invalid request', request_id:requestId, retryable:false, details:error?.details ?? null } }; }
7
+ export function validationFailure(error, requestId) { return { version:1, error:{ code:error?.code ?? 'invalid_request', message:error?.message ?? 'invalid request', request_id:requestId, retryable:error?.retryable===true, details:error?.details ?? null } }; }
8
8
  export const cursorToken = () => randomBytes(32).toString('base64url');
9
9
  export const cursorHash = token => createHash('sha256').update(token).digest('hex');
10
10
  export const sha256 = value => createHash('sha256').update(value).digest('hex');