@ours.network/cowork 0.2.0 → 0.3.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.
package/dist/daemon.js CHANGED
@@ -5492,7 +5492,108 @@ function isStrictRfc3339(value) {
5492
5492
  const days = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
5493
5493
  return day >= 1 && day <= days[month - 1];
5494
5494
  }
5495
- var MAX_TEXT_BYTES, MAX_ROLE_BYTES, NonEmptyStringSchema, PositiveSafeIntegerSchema, LowerCrockfordUlidSchema, Rfc3339Schema, RoleSchema, MissionTextSchema, MessageTextSchema, RoomStateSchema, InviteModeSchema, InviteStateSchema, RelayStatusSchema, SeatSchema, RoomInviteSchema, MissionSchema, RoomSchema, CreateRoomInputSchema, UpdateRoomInputSchema, PostMessageInputSchema, AuthorSnapshotSchema, RecordCommonShape, AppendCommonShape, MessageShape, RelayIntentShape, RelayResultShape, CloseNoticeIntentShape, CloseNoticeResultShape, MessageRecordSchema, RelayIntentRecordSchema, RelayResultRecordSchema, CloseNoticeIntentRecordSchema, CloseNoticeResultRecordSchema, RawCommunicationRecordSchema, CommunicationRecordSchema, AppendRecordSchema;
5495
+ function refineRoomLineage(room, context) {
5496
+ const pendingIdentityName = `cowork-room-${room.room_id}`;
5497
+ const exactPacketPending = room.state === "provisioning" && room.status === "packet_pending" && room.identity_name === pendingIdentityName && room.invites.length === 0 && room.seats.length === 0 && room.activated_at === void 0 && room.closed_at === void 0;
5498
+ if (room.identity_cid === "" && !exactPacketPending) {
5499
+ context.addIssue({
5500
+ code: external_exports.ZodIssueCode.custom,
5501
+ path: ["identity_cid"],
5502
+ message: "empty identity_cid is reserved for the exact packet_pending provisioning sentinel"
5503
+ });
5504
+ }
5505
+ if (room.identity_cid !== "" && room.status === "packet_pending") {
5506
+ context.addIssue({
5507
+ code: external_exports.ZodIssueCode.custom,
5508
+ path: ["status"],
5509
+ message: "packet_pending status requires an empty identity_cid"
5510
+ });
5511
+ }
5512
+ const pendingByRecovery = /* @__PURE__ */ new Map();
5513
+ for (const [index, invite] of room.invites.entries()) {
5514
+ if (invite.recovery_of === void 0) continue;
5515
+ const recoveryOf = invite.recovery_of;
5516
+ const source = room.invites.find((candidate) => candidate.invite_id === recoveryOf);
5517
+ const validSourceState = invite.state === "receipt_pending" ? source?.state === "replacement_required" : invite.state === "live" || invite.state === "consumed" || invite.state === "replacement_required" ? source?.state === "revoked" : invite.state === "revoked" ? invite.recovery_confirmed === true ? source?.state === "revoked" : source?.state === "replacement_required" || source?.state === "revoked" : false;
5518
+ if (!source || source.invite_id === invite.invite_id || !validSourceState) {
5519
+ context.addIssue({
5520
+ code: external_exports.ZodIssueCode.custom,
5521
+ path: ["invites", index, "recovery_of"],
5522
+ message: "recovery_of must point to a source invite in the state required by this recovery lineage"
5523
+ });
5524
+ } else if (invite.mode !== source.mode || invite.role !== source.role || invite.min_accepts !== source.min_accepts) {
5525
+ context.addIssue({
5526
+ code: external_exports.ZodIssueCode.custom,
5527
+ path: ["invites", index],
5528
+ message: "receipt_pending descriptor must copy source mode, role, and min_accepts"
5529
+ });
5530
+ }
5531
+ if (invite.state === "receipt_pending") {
5532
+ const count = (pendingByRecovery.get(recoveryOf) ?? 0) + 1;
5533
+ pendingByRecovery.set(recoveryOf, count);
5534
+ if (count > 1) {
5535
+ context.addIssue({
5536
+ code: external_exports.ZodIssueCode.custom,
5537
+ path: ["invites", index, "recovery_of"],
5538
+ message: "only one receipt_pending invite may exist per recovery_of pointer"
5539
+ });
5540
+ }
5541
+ }
5542
+ }
5543
+ }
5544
+ function migrateRoomV1(room, mintParticipantId) {
5545
+ return RoomSchema.parse({
5546
+ ...room,
5547
+ version: 2,
5548
+ mission: { ...room.mission, briefing_version: 1 },
5549
+ role_briefings: {},
5550
+ anonymous: false,
5551
+ quiet_membership: false,
5552
+ membership_epoch: 0,
5553
+ seats: room.seats.map((seat) => ({
5554
+ ...seat,
5555
+ participant_id: LowerCrockfordUlidSchema.parse(mintParticipantId()),
5556
+ state: "active"
5557
+ }))
5558
+ });
5559
+ }
5560
+ function refineMessageCategory(message, context) {
5561
+ const requires = (field, present) => {
5562
+ if (present && message[field] === void 0) {
5563
+ context.addIssue({
5564
+ code: external_exports.ZodIssueCode.custom,
5565
+ path: [field],
5566
+ message: `${message.category} messages require ${field}`
5567
+ });
5568
+ }
5569
+ if (!present && message[field] !== void 0) {
5570
+ context.addIssue({
5571
+ code: external_exports.ZodIssueCode.custom,
5572
+ path: [field],
5573
+ message: `${field} is forbidden on ${message.category} messages`
5574
+ });
5575
+ }
5576
+ };
5577
+ requires("briefing_role", message.category === "role_briefing");
5578
+ requires("membership", message.category === "membership");
5579
+ if (message.category === "role_briefing" && message.briefing_version === void 0) {
5580
+ context.addIssue({
5581
+ code: external_exports.ZodIssueCode.custom,
5582
+ path: ["briefing_version"],
5583
+ message: "role_briefing messages require briefing_version"
5584
+ });
5585
+ }
5586
+ if (message.category === "chat" || message.category === "membership") {
5587
+ if (message.briefing_version !== void 0) {
5588
+ context.addIssue({
5589
+ code: external_exports.ZodIssueCode.custom,
5590
+ path: ["briefing_version"],
5591
+ message: `briefing_version is forbidden on ${message.category} messages`
5592
+ });
5593
+ }
5594
+ }
5595
+ }
5596
+ var MAX_TEXT_BYTES, MAX_ROLE_BYTES, NonEmptyStringSchema, PositiveSafeIntegerSchema, LowerCrockfordUlidSchema, Rfc3339Schema, RoleSchema, MissionTextSchema, MessageTextSchema, RoomStateSchema, SeatStateSchema, InviteModeSchema, DEFAULT_ROLE, InviteStateSchema, RelayStatusSchema, SeatV1Schema, SeatSchema, RoomInviteSchema, MissionV1Schema, MissionSchema, RoleBriefingSchema, RoomCommonShape, RoomV1Schema, RoomSchema, CreateRoomInputSchema, UpdateRoomInputSchema, RoleBriefingSetInputSchema, RoleBriefingDeleteInputSchema, PostMessageInputSchema, AuthorSnapshotSchema, RecordCommonShape, AppendCommonShape, MembershipNoticeSchema, AuthorAliasSchema, MessageShape, RelayIntentShape, RelayResultStatusSchema, RelayResultShape, MembershipIntentShape, MembershipResultShape, CloseNoticeIntentShape, CloseNoticeResultShape, MessageRecordSchema, RelayIntentRecordSchema, RelayResultRecordSchema, MembershipIntentRecordSchema, MembershipResultRecordSchema, CloseNoticeIntentRecordSchema, CloseNoticeResultRecordSchema, RawCommunicationRecordSchema, CommunicationRecordSchema, AppendRecordSchema;
5496
5597
  var init_contracts = __esm({
5497
5598
  "src/contracts.ts"() {
5498
5599
  "use strict";
@@ -5510,7 +5611,9 @@ var init_contracts = __esm({
5510
5611
  MissionTextSchema = utf8Bounded("mission text", MAX_TEXT_BYTES);
5511
5612
  MessageTextSchema = utf8Bounded("message text", MAX_TEXT_BYTES);
5512
5613
  RoomStateSchema = external_exports.enum(["provisioning", "active", "closing", "closed"]);
5614
+ SeatStateSchema = external_exports.enum(["active", "removed"]);
5513
5615
  InviteModeSchema = external_exports.enum(["one_time", "public"]);
5616
+ DEFAULT_ROLE = "Participant";
5514
5617
  InviteStateSchema = external_exports.enum([
5515
5618
  "live",
5516
5619
  "consumed",
@@ -5519,13 +5622,54 @@ var init_contracts = __esm({
5519
5622
  "receipt_pending"
5520
5623
  ]);
5521
5624
  RelayStatusSchema = external_exports.enum(["queued", "send_failed"]);
5522
- SeatSchema = external_exports.object({
5625
+ SeatV1Schema = external_exports.object({
5523
5626
  identity: NonEmptyStringSchema,
5524
5627
  display_name: NonEmptyStringSchema,
5525
5628
  role: RoleSchema,
5526
5629
  invite_id: NonEmptyStringSchema,
5527
5630
  accepted_at: Rfc3339Schema
5528
5631
  }).strict();
5632
+ SeatSchema = external_exports.object({
5633
+ identity: NonEmptyStringSchema,
5634
+ display_name: NonEmptyStringSchema,
5635
+ role: RoleSchema,
5636
+ invite_id: NonEmptyStringSchema,
5637
+ accepted_at: Rfc3339Schema,
5638
+ participant_id: LowerCrockfordUlidSchema,
5639
+ state: SeatStateSchema,
5640
+ alias: NonEmptyStringSchema.optional(),
5641
+ removed_at: Rfc3339Schema.optional(),
5642
+ removed_epoch: external_exports.number().int().nonnegative().safe().optional(),
5643
+ replaces_seat: LowerCrockfordUlidSchema.optional(),
5644
+ bounced_at: Rfc3339Schema.optional()
5645
+ }).strict().superRefine((seat, context) => {
5646
+ if (seat.state === "removed") {
5647
+ if (seat.removed_at === void 0) {
5648
+ context.addIssue({
5649
+ code: external_exports.ZodIssueCode.custom,
5650
+ path: ["removed_at"],
5651
+ message: "removed seats require removed_at"
5652
+ });
5653
+ }
5654
+ if (seat.removed_epoch === void 0) {
5655
+ context.addIssue({
5656
+ code: external_exports.ZodIssueCode.custom,
5657
+ path: ["removed_epoch"],
5658
+ message: "removed seats require removed_epoch"
5659
+ });
5660
+ }
5661
+ } else {
5662
+ for (const field of ["removed_at", "removed_epoch", "bounced_at"]) {
5663
+ if (seat[field] !== void 0) {
5664
+ context.addIssue({
5665
+ code: external_exports.ZodIssueCode.custom,
5666
+ path: [field],
5667
+ message: `${field} is reserved for removed seats`
5668
+ });
5669
+ }
5670
+ }
5671
+ }
5672
+ });
5529
5673
  RoomInviteSchema = external_exports.object({
5530
5674
  invite_id: NonEmptyStringSchema,
5531
5675
  mode: InviteModeSchema,
@@ -5535,7 +5679,8 @@ var init_contracts = __esm({
5535
5679
  state: InviteStateSchema,
5536
5680
  recovery_of: NonEmptyStringSchema.optional(),
5537
5681
  recovery_confirmed: external_exports.boolean().optional(),
5538
- created_at: Rfc3339Schema
5682
+ created_at: Rfc3339Schema,
5683
+ replaces_seat: LowerCrockfordUlidSchema.optional()
5539
5684
  }).strict().superRefine((invite, context) => {
5540
5685
  if (invite.mode === "one_time" && invite.min_accepts !== 1) {
5541
5686
  context.addIssue({
@@ -5587,81 +5732,137 @@ var init_contracts = __esm({
5587
5732
  });
5588
5733
  }
5589
5734
  });
5590
- MissionSchema = external_exports.object({
5735
+ MissionV1Schema = external_exports.object({
5591
5736
  goal: MissionTextSchema,
5592
5737
  briefing: MissionTextSchema
5593
5738
  }).strict();
5594
- RoomSchema = external_exports.object({
5595
- version: external_exports.literal(1),
5739
+ MissionSchema = external_exports.object({
5740
+ goal: MissionTextSchema,
5741
+ briefing: MissionTextSchema,
5742
+ briefing_version: PositiveSafeIntegerSchema
5743
+ }).strict();
5744
+ RoleBriefingSchema = external_exports.object({
5745
+ text: MissionTextSchema,
5746
+ version: PositiveSafeIntegerSchema,
5747
+ updated_at: Rfc3339Schema
5748
+ }).strict();
5749
+ RoomCommonShape = {
5596
5750
  room_id: LowerCrockfordUlidSchema,
5597
5751
  identity_name: NonEmptyStringSchema,
5598
5752
  identity_cid: external_exports.string(),
5599
- mission: MissionSchema,
5600
5753
  state: RoomStateSchema,
5601
5754
  status: NonEmptyStringSchema.optional(),
5602
5755
  invites: external_exports.array(RoomInviteSchema),
5603
- seats: external_exports.array(SeatSchema),
5604
5756
  created_at: Rfc3339Schema,
5605
5757
  activated_at: Rfc3339Schema.optional(),
5606
5758
  closed_at: Rfc3339Schema.optional()
5759
+ };
5760
+ RoomV1Schema = external_exports.object({
5761
+ ...RoomCommonShape,
5762
+ version: external_exports.literal(1),
5763
+ mission: MissionV1Schema,
5764
+ seats: external_exports.array(SeatV1Schema)
5765
+ }).strict().superRefine(refineRoomLineage);
5766
+ RoomSchema = external_exports.object({
5767
+ ...RoomCommonShape,
5768
+ version: external_exports.literal(2),
5769
+ mission: MissionSchema,
5770
+ role_briefings: external_exports.record(RoleSchema, RoleBriefingSchema),
5771
+ anonymous: external_exports.boolean(),
5772
+ quiet_membership: external_exports.boolean(),
5773
+ membership_epoch: external_exports.number().int().nonnegative().safe(),
5774
+ seats: external_exports.array(SeatSchema)
5607
5775
  }).strict().superRefine((room, context) => {
5608
- const pendingIdentityName = `cowork-room-${room.room_id}`;
5609
- const exactPacketPending = room.state === "provisioning" && room.status === "packet_pending" && room.identity_name === pendingIdentityName && room.invites.length === 0 && room.seats.length === 0 && room.activated_at === void 0 && room.closed_at === void 0;
5610
- if (room.identity_cid === "" && !exactPacketPending) {
5611
- context.addIssue({
5612
- code: external_exports.ZodIssueCode.custom,
5613
- path: ["identity_cid"],
5614
- message: "empty identity_cid is reserved for the exact packet_pending provisioning sentinel"
5615
- });
5616
- }
5617
- if (room.identity_cid !== "" && room.status === "packet_pending") {
5618
- context.addIssue({
5619
- code: external_exports.ZodIssueCode.custom,
5620
- path: ["status"],
5621
- message: "packet_pending status requires an empty identity_cid"
5622
- });
5623
- }
5624
- const pendingByRecovery = /* @__PURE__ */ new Map();
5625
- for (const [index, invite] of room.invites.entries()) {
5626
- if (invite.recovery_of === void 0) continue;
5627
- const recoveryOf = invite.recovery_of;
5628
- const source = room.invites.find((candidate) => candidate.invite_id === recoveryOf);
5629
- const validSourceState = invite.state === "receipt_pending" ? source?.state === "replacement_required" : invite.state === "live" || invite.state === "consumed" || invite.state === "replacement_required" ? source?.state === "revoked" : invite.state === "revoked" ? invite.recovery_confirmed === true ? source?.state === "revoked" : source?.state === "replacement_required" || source?.state === "revoked" : false;
5630
- if (!source || source.invite_id === invite.invite_id || !validSourceState) {
5776
+ refineRoomLineage(room, context);
5777
+ const byParticipant = /* @__PURE__ */ new Map();
5778
+ const activeAliases = /* @__PURE__ */ new Set();
5779
+ for (const [index, seat] of room.seats.entries()) {
5780
+ if (byParticipant.has(seat.participant_id)) {
5631
5781
  context.addIssue({
5632
5782
  code: external_exports.ZodIssueCode.custom,
5633
- path: ["invites", index, "recovery_of"],
5634
- message: "recovery_of must point to a source invite in the state required by this recovery lineage"
5783
+ path: ["seats", index, "participant_id"],
5784
+ message: "participant_id must be unique within the room"
5635
5785
  });
5636
- } else if (invite.mode !== source.mode || invite.role !== source.role || invite.min_accepts !== source.min_accepts) {
5786
+ }
5787
+ byParticipant.set(seat.participant_id, seat);
5788
+ if (room.anonymous && seat.alias === void 0) {
5637
5789
  context.addIssue({
5638
5790
  code: external_exports.ZodIssueCode.custom,
5639
- path: ["invites", index],
5640
- message: "receipt_pending descriptor must copy source mode, role, and min_accepts"
5791
+ path: ["seats", index, "alias"],
5792
+ message: "anonymous rooms require an alias on every seat"
5641
5793
  });
5642
5794
  }
5643
- if (invite.state === "receipt_pending") {
5644
- const count = (pendingByRecovery.get(recoveryOf) ?? 0) + 1;
5645
- pendingByRecovery.set(recoveryOf, count);
5646
- if (count > 1) {
5795
+ if (!room.anonymous && seat.alias !== void 0) {
5796
+ context.addIssue({
5797
+ code: external_exports.ZodIssueCode.custom,
5798
+ path: ["seats", index, "alias"],
5799
+ message: "aliases are reserved for anonymous rooms"
5800
+ });
5801
+ }
5802
+ if (seat.state === "active" && seat.alias !== void 0) {
5803
+ if (activeAliases.has(seat.alias)) {
5647
5804
  context.addIssue({
5648
5805
  code: external_exports.ZodIssueCode.custom,
5649
- path: ["invites", index, "recovery_of"],
5650
- message: "only one receipt_pending invite may exist per recovery_of pointer"
5806
+ path: ["seats", index, "alias"],
5807
+ message: "active seats must hold distinct aliases"
5651
5808
  });
5652
5809
  }
5810
+ activeAliases.add(seat.alias);
5811
+ }
5812
+ if (seat.removed_epoch !== void 0 && seat.removed_epoch > room.membership_epoch) {
5813
+ context.addIssue({
5814
+ code: external_exports.ZodIssueCode.custom,
5815
+ path: ["seats", index, "removed_epoch"],
5816
+ message: "removed_epoch cannot exceed the room membership_epoch"
5817
+ });
5818
+ }
5819
+ }
5820
+ for (const [index, seat] of room.seats.entries()) {
5821
+ if (seat.replaces_seat === void 0) continue;
5822
+ const predecessor = byParticipant.get(seat.replaces_seat);
5823
+ if (!predecessor || predecessor === seat || predecessor.state !== "removed") {
5824
+ context.addIssue({
5825
+ code: external_exports.ZodIssueCode.custom,
5826
+ path: ["seats", index, "replaces_seat"],
5827
+ message: "replaces_seat must reference a removed seat in this room"
5828
+ });
5829
+ continue;
5830
+ }
5831
+ if (predecessor.role !== seat.role) {
5832
+ context.addIssue({
5833
+ code: external_exports.ZodIssueCode.custom,
5834
+ path: ["seats", index, "role"],
5835
+ message: "a replacement seat must inherit the predecessor role"
5836
+ });
5837
+ }
5838
+ if (room.anonymous && seat.alias !== predecessor.alias) {
5839
+ context.addIssue({
5840
+ code: external_exports.ZodIssueCode.custom,
5841
+ path: ["seats", index, "alias"],
5842
+ message: "an anonymous replacement seat must inherit the predecessor alias"
5843
+ });
5653
5844
  }
5654
5845
  }
5655
5846
  });
5656
5847
  CreateRoomInputSchema = external_exports.object({
5657
5848
  goal: MissionTextSchema,
5658
- briefing: MissionTextSchema
5849
+ briefing: MissionTextSchema,
5850
+ anonymous: external_exports.boolean().optional(),
5851
+ quiet_membership: external_exports.boolean().optional()
5659
5852
  }).strict();
5660
5853
  UpdateRoomInputSchema = external_exports.object({
5661
5854
  goal: MissionTextSchema.optional(),
5662
5855
  briefing: MissionTextSchema.optional(),
5663
- status: NonEmptyStringSchema.optional()
5856
+ status: NonEmptyStringSchema.optional(),
5857
+ quiet_membership: external_exports.boolean().optional()
5664
5858
  }).strict().refine((input) => Object.keys(input).length > 0, "at least one setting is required");
5859
+ RoleBriefingSetInputSchema = external_exports.object({
5860
+ role: RoleSchema,
5861
+ text: MissionTextSchema
5862
+ }).strict();
5863
+ RoleBriefingDeleteInputSchema = external_exports.object({
5864
+ role: RoleSchema
5865
+ }).strict();
5665
5866
  PostMessageInputSchema = external_exports.object({
5666
5867
  text: MessageTextSchema
5667
5868
  }).strict();
@@ -5682,11 +5883,25 @@ var init_contracts = __esm({
5682
5883
  room_id: LowerCrockfordUlidSchema,
5683
5884
  at: Rfc3339Schema
5684
5885
  };
5886
+ MembershipNoticeSchema = external_exports.object({
5887
+ action: external_exports.enum(["remove"]),
5888
+ alias: NonEmptyStringSchema.optional(),
5889
+ role: RoleSchema.optional(),
5890
+ epoch: external_exports.number().int().nonnegative().safe()
5891
+ }).strict();
5892
+ AuthorAliasSchema = external_exports.object({
5893
+ participant_id: LowerCrockfordUlidSchema,
5894
+ alias: NonEmptyStringSchema
5895
+ }).strict();
5685
5896
  MessageShape = {
5686
5897
  kind: external_exports.literal("message"),
5687
5898
  message_id: LowerCrockfordUlidSchema,
5688
5899
  author: AuthorSnapshotSchema,
5689
- category: external_exports.enum(["briefing", "chat"]),
5900
+ author_alias: AuthorAliasSchema.optional(),
5901
+ category: external_exports.enum(["briefing", "role_briefing", "chat", "membership"]),
5902
+ briefing_role: RoleSchema.optional(),
5903
+ briefing_version: PositiveSafeIntegerSchema.optional(),
5904
+ membership: MembershipNoticeSchema.optional(),
5690
5905
  text: MessageTextSchema,
5691
5906
  recipient_identities: external_exports.array(NonEmptyStringSchema).superRefine((identities, context) => {
5692
5907
  const seen = /* @__PURE__ */ new Set();
@@ -5709,14 +5924,34 @@ var init_contracts = __esm({
5709
5924
  message_id: LowerCrockfordUlidSchema,
5710
5925
  recipient_identity: NonEmptyStringSchema
5711
5926
  };
5927
+ RelayResultStatusSchema = external_exports.enum(["queued", "send_failed", "skipped_removed"]);
5712
5928
  RelayResultShape = {
5713
5929
  kind: external_exports.literal("relay_result"),
5714
5930
  intent_record_id: NonEmptyStringSchema,
5715
5931
  message_id: LowerCrockfordUlidSchema,
5716
5932
  recipient_identity: NonEmptyStringSchema,
5717
- status: RelayStatusSchema,
5933
+ status: RelayResultStatusSchema,
5718
5934
  wire_id: NonEmptyStringSchema.optional()
5719
5935
  };
5936
+ MembershipIntentShape = {
5937
+ kind: external_exports.literal("membership_intent"),
5938
+ action: external_exports.enum(["remove"]),
5939
+ participant_id: LowerCrockfordUlidSchema,
5940
+ recipient_identity: NonEmptyStringSchema,
5941
+ role: RoleSchema,
5942
+ alias: NonEmptyStringSchema.optional(),
5943
+ epoch: PositiveSafeIntegerSchema,
5944
+ notify: external_exports.boolean()
5945
+ };
5946
+ MembershipResultShape = {
5947
+ kind: external_exports.literal("membership_result"),
5948
+ intent_record_id: NonEmptyStringSchema,
5949
+ participant_id: LowerCrockfordUlidSchema,
5950
+ status: RelayStatusSchema,
5951
+ notified: external_exports.boolean(),
5952
+ key_material_retained: external_exports.literal(true),
5953
+ uncertain_after_restart: external_exports.literal(true).optional()
5954
+ };
5720
5955
  CloseNoticeIntentShape = {
5721
5956
  kind: external_exports.literal("close_notice_intent"),
5722
5957
  recipient_identity: NonEmptyStringSchema
@@ -5733,12 +5968,16 @@ var init_contracts = __esm({
5733
5968
  MessageRecordSchema = external_exports.object({ ...RecordCommonShape, ...MessageShape }).strict();
5734
5969
  RelayIntentRecordSchema = external_exports.object({ ...RecordCommonShape, ...RelayIntentShape }).strict();
5735
5970
  RelayResultRecordSchema = external_exports.object({ ...RecordCommonShape, ...RelayResultShape }).strict();
5971
+ MembershipIntentRecordSchema = external_exports.object({ ...RecordCommonShape, ...MembershipIntentShape }).strict();
5972
+ MembershipResultRecordSchema = external_exports.object({ ...RecordCommonShape, ...MembershipResultShape }).strict();
5736
5973
  CloseNoticeIntentRecordSchema = external_exports.object({ ...RecordCommonShape, ...CloseNoticeIntentShape }).strict();
5737
5974
  CloseNoticeResultRecordSchema = external_exports.object({ ...RecordCommonShape, ...CloseNoticeResultShape }).strict();
5738
5975
  RawCommunicationRecordSchema = external_exports.discriminatedUnion("kind", [
5739
5976
  MessageRecordSchema,
5740
5977
  RelayIntentRecordSchema,
5741
5978
  RelayResultRecordSchema,
5979
+ MembershipIntentRecordSchema,
5980
+ MembershipResultRecordSchema,
5742
5981
  CloseNoticeIntentRecordSchema,
5743
5982
  CloseNoticeResultRecordSchema
5744
5983
  ]);
@@ -5750,19 +5989,54 @@ var init_contracts = __esm({
5750
5989
  message: 'record_id must equal room_id + ":" + seq'
5751
5990
  });
5752
5991
  }
5992
+ if (record.kind === "message") refineMessageCategory(record, context);
5753
5993
  });
5754
5994
  AppendRecordSchema = external_exports.discriminatedUnion("kind", [
5755
5995
  external_exports.object({ ...AppendCommonShape, ...MessageShape }).strict(),
5756
5996
  external_exports.object({ ...AppendCommonShape, ...RelayIntentShape }).strict(),
5757
5997
  external_exports.object({ ...AppendCommonShape, ...RelayResultShape }).strict(),
5998
+ external_exports.object({ ...AppendCommonShape, ...MembershipIntentShape }).strict(),
5999
+ external_exports.object({ ...AppendCommonShape, ...MembershipResultShape }).strict(),
5758
6000
  external_exports.object({ ...AppendCommonShape, ...CloseNoticeIntentShape }).strict(),
5759
6001
  external_exports.object({ ...AppendCommonShape, ...CloseNoticeResultShape }).strict()
5760
- ]);
6002
+ ]).superRefine((record, context) => {
6003
+ if (record.kind === "message") refineMessageCategory(record, context);
6004
+ });
5761
6005
  }
5762
6006
  });
5763
6007
 
5764
- // src/intake.ts
6008
+ // src/ulid.ts
5765
6009
  import { randomBytes as randomBytes2 } from "node:crypto";
6010
+ function generateUlid() {
6011
+ let time = Date.now();
6012
+ const output = new Array(26);
6013
+ for (let index = 9; index >= 0; index -= 1) {
6014
+ output[index] = CROCKFORD[time % 32];
6015
+ time = Math.floor(time / 32);
6016
+ }
6017
+ const entropy = randomBytes2(10);
6018
+ let bits = 0;
6019
+ let value = 0;
6020
+ let byteIndex = 0;
6021
+ for (let index = 10; index < 26; index += 1) {
6022
+ while (bits < 5) {
6023
+ value = value << 8 | entropy[byteIndex++];
6024
+ bits += 8;
6025
+ }
6026
+ bits -= 5;
6027
+ output[index] = CROCKFORD[value >>> bits & 31];
6028
+ }
6029
+ return output.join("");
6030
+ }
6031
+ var CROCKFORD;
6032
+ var init_ulid = __esm({
6033
+ "src/ulid.ts"() {
6034
+ "use strict";
6035
+ CROCKFORD = "0123456789abcdefghjkmnpqrstvwxyz";
6036
+ }
6037
+ });
6038
+
6039
+ // src/intake.ts
5766
6040
  function canonicalJson(value) {
5767
6041
  const encoded = JSON.stringify(canonicalValue(value));
5768
6042
  if (encoded === void 0) throw new TypeError("canonical JSON value is not serializable");
@@ -5783,34 +6057,25 @@ function canonicalValue(value) {
5783
6057
  function unique(values) {
5784
6058
  return [...new Set(values)];
5785
6059
  }
5786
- function generateUlid() {
5787
- let time = Date.now();
5788
- const output = new Array(26);
5789
- for (let index = 9; index >= 0; index -= 1) {
5790
- output[index] = CROCKFORD[time % 32];
5791
- time = Math.floor(time / 32);
5792
- }
5793
- const entropy = randomBytes2(10);
5794
- let bits = 0;
5795
- let value = 0;
5796
- let byteIndex = 0;
5797
- for (let index = 10; index < 26; index += 1) {
5798
- while (bits < 5) {
5799
- value = value << 8 | entropy[byteIndex++];
5800
- bits += 8;
5801
- }
5802
- bits -= 5;
5803
- output[index] = CROCKFORD[value >>> bits & 31];
6060
+ function wireKind(category) {
6061
+ switch (category) {
6062
+ case "briefing":
6063
+ return "room_briefing";
6064
+ case "role_briefing":
6065
+ return "room_role_briefing";
6066
+ case "membership":
6067
+ return "room_membership";
6068
+ default:
6069
+ return "room_msg";
5804
6070
  }
5805
- return output.join("");
5806
6071
  }
5807
- var CROCKFORD, IntakePump;
6072
+ var IntakePump;
5808
6073
  var init_intake = __esm({
5809
6074
  "src/intake.ts"() {
5810
6075
  "use strict";
5811
6076
  init_zod();
5812
6077
  init_contracts();
5813
- CROCKFORD = "0123456789abcdefghjkmnpqrstvwxyz";
6078
+ init_ulid();
5814
6079
  IntakePump = class {
5815
6080
  store;
5816
6081
  packets;
@@ -5895,15 +6160,18 @@ var init_intake = __esm({
5895
6160
  }
5896
6161
  async processInboxItem(roomId, packet, item) {
5897
6162
  const room = await this.store.load(roomId);
5898
- const seat = room.seats.find((candidate) => candidate.identity === item.sender_id);
6163
+ const seat = room.seats.find(
6164
+ (candidate) => candidate.identity === item.sender_id && candidate.state === "active"
6165
+ );
5899
6166
  if (room.state !== "active" || !seat) {
6167
+ if (room.state === "active") await this.bounceRemovedSender(roomId, room, packet, item);
5900
6168
  await packet.consumeInbox([item.msg_id]);
5901
6169
  return;
5902
6170
  }
5903
6171
  const before = await this.store.read(roomId);
5904
6172
  let message = this.findSourceMessage(before, item);
5905
6173
  if (!message) {
5906
- const recipientIdentities = unique(room.seats.map((recipient) => recipient.identity).filter((identity) => identity !== seat.identity));
6174
+ const recipientIdentities = unique(room.seats.filter((recipient) => recipient.state === "active").map((recipient) => recipient.identity).filter((identity) => identity !== seat.identity));
5907
6175
  const appended = await this.store.append(roomId, {
5908
6176
  version: 1,
5909
6177
  kind: "message",
@@ -5915,6 +6183,9 @@ var init_intake = __esm({
5915
6183
  display_name: seat.display_name,
5916
6184
  role: seat.role
5917
6185
  },
6186
+ // In an anonymous room the archive keeps both identities (INV-R4);
6187
+ // the relay pump substitutes the alias into every outbound body.
6188
+ ...room.anonymous && seat.alias !== void 0 ? { author_alias: { participant_id: seat.participant_id, alias: seat.alias } } : {},
5918
6189
  category: "chat",
5919
6190
  text: item.text,
5920
6191
  recipient_identities: recipientIdentities,
@@ -5927,6 +6198,28 @@ var init_intake = __esm({
5927
6198
  await this.completeMessageIntents(roomId, message);
5928
6199
  await packet.consumeInbox([item.msg_id]);
5929
6200
  }
6201
+ /**
6202
+ * One content-free self-assertion per removed seat (spec §5.2, OC-8), so a
6203
+ * healthy ex-client stops sending. The durable bounced_at mark precedes the
6204
+ * best-effort send: at-most-once, and a hostile peer gets nothing further.
6205
+ */
6206
+ async bounceRemovedSender(roomId, room, packet, item) {
6207
+ const removed = room.seats.find(
6208
+ (candidate) => candidate.identity === item.sender_id && candidate.state === "removed"
6209
+ );
6210
+ if (!removed || removed.bounced_at !== void 0) return;
6211
+ if (room.seats.some(
6212
+ (candidate) => candidate.identity === item.sender_id && candidate.state === "active"
6213
+ )) return;
6214
+ const seats = room.seats.map((candidate) => candidate.participant_id === removed.participant_id ? { ...candidate, bounced_at: this.now() } : candidate);
6215
+ await this.store.save(RoomSchema.parse({ ...room, seats }));
6216
+ try {
6217
+ const unsigned = { version: 1, kind: "room_not_member", room_id: roomId };
6218
+ const signature = await packet.sign(canonicalJson(unsigned));
6219
+ await packet.send(item.sender_id, canonicalJson({ ...unsigned, signature }));
6220
+ } catch {
6221
+ }
6222
+ }
5930
6223
  async completeSnapshotIntents(roomId) {
5931
6224
  const records = await this.store.read(roomId);
5932
6225
  for (const message of records.filter(
@@ -5951,6 +6244,9 @@ var init_intake = __esm({
5951
6244
  }
5952
6245
  async relayPendingUnlocked(roomId, packet) {
5953
6246
  const records = await this.store.read(roomId);
6247
+ const room = await this.store.load(roomId);
6248
+ const activeCids = new Set(room.seats.filter((seat) => seat.state === "active").map((seat) => seat.identity));
6249
+ const removedCids = new Set(room.seats.filter((seat) => seat.state === "removed").map((seat) => seat.identity));
5954
6250
  const messages = new Map(records.filter((record) => record.kind === "message").map((message) => [message.message_id, message]));
5955
6251
  const completed = new Set(records.filter((record) => record.kind === "relay_result").map((result) => result.kind === "relay_result" ? result.intent_record_id : ""));
5956
6252
  for (const intent of records.filter(
@@ -5960,14 +6256,37 @@ var init_intake = __esm({
5960
6256
  const message = messages.get(intent.message_id);
5961
6257
  if (!message) continue;
5962
6258
  if (!message.recipient_identities.includes(intent.recipient_identity)) continue;
6259
+ if (!activeCids.has(intent.recipient_identity) && removedCids.has(intent.recipient_identity)) {
6260
+ const skipped = await this.store.append(roomId, {
6261
+ version: 1,
6262
+ kind: "relay_result",
6263
+ room_id: roomId,
6264
+ at: this.now(),
6265
+ intent_record_id: intent.record_id,
6266
+ message_id: intent.message_id,
6267
+ recipient_identity: intent.recipient_identity,
6268
+ status: "skipped_removed"
6269
+ });
6270
+ if (skipped.kind !== "relay_result") throw new Error("storage returned the wrong relay result kind");
6271
+ completed.add(intent.record_id);
6272
+ continue;
6273
+ }
5963
6274
  const unsigned = {
5964
6275
  version: 1,
5965
- kind: message.category === "briefing" ? "room_briefing" : "room_msg",
6276
+ kind: wireKind(message.category),
5966
6277
  room_id: roomId,
5967
6278
  message_id: message.message_id,
5968
- author: message.author,
6279
+ // INV-R3: an anonymous author leaves the archive only in alias form.
6280
+ author: message.author_alias === void 0 ? message.author : {
6281
+ identity: message.author_alias.participant_id,
6282
+ display_name: message.author_alias.alias,
6283
+ role: message.author.role
6284
+ },
5969
6285
  text: message.text,
5970
- at: message.at
6286
+ at: message.at,
6287
+ ...message.briefing_role === void 0 ? {} : { briefing_role: message.briefing_role },
6288
+ ...message.briefing_version === void 0 ? {} : { briefing_version: message.briefing_version },
6289
+ ...message.membership === void 0 ? {} : { membership: message.membership }
5971
6290
  };
5972
6291
  const signature = await packet.sign(canonicalJson(unsigned));
5973
6292
  const body = canonicalJson({ ...unsigned, signature });
@@ -6012,27 +6331,11 @@ var init_intake = __esm({
6012
6331
  });
6013
6332
 
6014
6333
  // src/service.ts
6015
- import { randomBytes as randomBytes3 } from "node:crypto";
6016
- function generateUlid2() {
6017
- let time = Date.now();
6018
- const output = new Array(26);
6019
- for (let index = 9; index >= 0; index -= 1) {
6020
- output[index] = CROCKFORD2[time % 32];
6021
- time = Math.floor(time / 32);
6022
- }
6023
- const entropy = randomBytes3(10);
6024
- let bits = 0;
6025
- let value = 0;
6026
- let byteIndex = 0;
6027
- for (let index = 10; index < 26; index += 1) {
6028
- while (bits < 5) {
6029
- value = value << 8 | entropy[byteIndex++];
6030
- bits += 8;
6031
- }
6032
- bits -= 5;
6033
- output[index] = CROCKFORD2[value >>> bits & 31];
6034
- }
6035
- return output.join("");
6334
+ function activeSeats(room) {
6335
+ return room.seats.filter((seat) => seat.state === "active");
6336
+ }
6337
+ function mintAlias(seated, role) {
6338
+ return `${role} #${seated.filter((seat) => seat.role === role).length + 1}`;
6036
6339
  }
6037
6340
  function uniqueIdentities(identities) {
6038
6341
  return [...new Set(identities)];
@@ -6040,18 +6343,18 @@ function uniqueIdentities(identities) {
6040
6343
  function currentContactIdentities(packet) {
6041
6344
  return new Set(packet.listContacts().map((contact) => contact.container_id));
6042
6345
  }
6043
- var CROCKFORD2, ROOM_ROLE, CreateInviteInputSchema, HistoryOptionsSchema, DeleteRoomInputSchema, RoomServiceError, RoomService;
6346
+ var ROOM_ROLE, CreateInviteInputSchema, HistoryOptionsSchema, DeleteRoomInputSchema, RemoveParticipantInputSchema, ReplaceParticipantInputSchema, RoomServiceError, RoomService;
6044
6347
  var init_service = __esm({
6045
6348
  "src/service.ts"() {
6046
6349
  "use strict";
6047
6350
  init_zod();
6048
6351
  init_contracts();
6049
6352
  init_intake();
6050
- CROCKFORD2 = "0123456789abcdefghjkmnpqrstvwxyz";
6353
+ init_ulid();
6051
6354
  ROOM_ROLE = "room";
6052
6355
  CreateInviteInputSchema = external_exports.object({
6053
6356
  mode: InviteModeSchema,
6054
- role: RoleSchema,
6357
+ role: RoleSchema.optional(),
6055
6358
  min_accepts: external_exports.number().int().positive().safe()
6056
6359
  }).strict().superRefine((input, context) => {
6057
6360
  if (input.mode === "one_time" && input.min_accepts !== 1) {
@@ -6064,11 +6367,22 @@ var init_service = __esm({
6064
6367
  });
6065
6368
  HistoryOptionsSchema = external_exports.object({
6066
6369
  after: external_exports.number().int().nonnegative().safe().optional(),
6067
- limit: external_exports.number().int().positive().safe().optional()
6370
+ limit: external_exports.number().int().positive().safe().optional(),
6371
+ view: external_exports.enum(["operator", "participant"]).optional()
6068
6372
  }).strict();
6069
6373
  DeleteRoomInputSchema = external_exports.object({
6070
6374
  confirm: external_exports.literal(true)
6071
6375
  }).strict();
6376
+ RemoveParticipantInputSchema = external_exports.object({
6377
+ participant: external_exports.string().min(1),
6378
+ notify: external_exports.boolean().optional()
6379
+ }).strict();
6380
+ ReplaceParticipantInputSchema = external_exports.object({
6381
+ participant: external_exports.string().min(1),
6382
+ notify: external_exports.boolean().optional(),
6383
+ mode: InviteModeSchema.optional(),
6384
+ min_accepts: external_exports.number().int().positive().safe().optional()
6385
+ }).strict();
6072
6386
  RoomServiceError = class extends Error {
6073
6387
  constructor(message, options) {
6074
6388
  super(message, options);
@@ -6087,8 +6401,8 @@ var init_service = __esm({
6087
6401
  this.store = store;
6088
6402
  this.packets = packets;
6089
6403
  this.nowValue = options.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
6090
- this.nextRoomId = options.roomId ?? generateUlid2;
6091
- this.nextMessageId = options.messageId ?? generateUlid2;
6404
+ this.nextRoomId = options.roomId ?? generateUlid;
6405
+ this.nextMessageId = options.messageId ?? generateUlid;
6092
6406
  this.provisioningCheckpoint = options.provisioningCheckpoint ?? (() => {
6093
6407
  });
6094
6408
  this.intake = new IntakePump(store, packets, {
@@ -6102,14 +6416,18 @@ var init_service = __esm({
6102
6416
  const identityName = `cowork-room-${roomId}`;
6103
6417
  return this.lock(roomId, async () => {
6104
6418
  const provisional = RoomSchema.parse({
6105
- version: 1,
6419
+ version: 2,
6106
6420
  room_id: roomId,
6107
6421
  identity_name: identityName,
6108
6422
  // PacketRegistry needs the durable room directory to exist first. A
6109
6423
  // valid, explicitly provisional value lets startup resume this exact
6110
6424
  // two-resource boundary without claiming a packet CID yet.
6111
6425
  identity_cid: "",
6112
- mission: { goal: settings.goal, briefing: settings.briefing },
6426
+ mission: { goal: settings.goal, briefing: settings.briefing, briefing_version: 1 },
6427
+ role_briefings: {},
6428
+ anonymous: settings.anonymous ?? false,
6429
+ quiet_membership: settings.quiet_membership ?? false,
6430
+ membership_epoch: 0,
6113
6431
  state: "provisioning",
6114
6432
  status: "packet_pending",
6115
6433
  invites: [],
@@ -6200,18 +6518,73 @@ var init_service = __esm({
6200
6518
  async updateRoom(roomId, input) {
6201
6519
  const id = LowerCrockfordUlidSchema.parse(roomId);
6202
6520
  const settings = UpdateRoomInputSchema.parse(input);
6203
- return this.lock(id, async () => {
6521
+ const { room: updated, redelivered } = await this.lock(id, async () => {
6204
6522
  const room = await this.store.load(id);
6205
6523
  this.assertMutable(room, "update");
6524
+ const briefingChanged = settings.briefing !== void 0 && settings.briefing !== room.mission.briefing;
6206
6525
  const mission = {
6207
6526
  goal: settings.goal ?? room.mission.goal,
6208
- briefing: settings.briefing ?? room.mission.briefing
6527
+ briefing: settings.briefing ?? room.mission.briefing,
6528
+ briefing_version: briefingChanged ? room.mission.briefing_version + 1 : room.mission.briefing_version
6209
6529
  };
6210
- return this.store.save(RoomSchema.parse({
6530
+ const next = await this.store.save(RoomSchema.parse({
6211
6531
  ...room,
6212
6532
  mission,
6533
+ ...settings.quiet_membership === void 0 ? {} : { quiet_membership: settings.quiet_membership },
6213
6534
  ...settings.status === void 0 ? {} : { status: settings.status }
6214
6535
  }));
6536
+ if (briefingChanged && next.state === "active") {
6537
+ await this.redeliverCommonBriefing(next);
6538
+ return { room: next, redelivered: true };
6539
+ }
6540
+ return { room: next, redelivered: false };
6541
+ });
6542
+ if (redelivered) await this.intake.resumePending(id);
6543
+ return updated;
6544
+ }
6545
+ /** Author or edit one role's briefing; an edit bumps its version and re-delivers to seats of that role only (spec §3.3). */
6546
+ async setRoleBriefing(roomId, input) {
6547
+ const id = LowerCrockfordUlidSchema.parse(roomId);
6548
+ const request = RoleBriefingSetInputSchema.parse(input);
6549
+ const { room: updated, redelivered } = await this.lock(id, async () => {
6550
+ const room = await this.store.load(id);
6551
+ this.assertMutable(room, "set a role briefing for");
6552
+ const existing = room.role_briefings[request.role];
6553
+ if (existing && existing.text === request.text) return { room, redelivered: false };
6554
+ const briefing = {
6555
+ text: request.text,
6556
+ version: existing === void 0 ? 1 : existing.version + 1,
6557
+ updated_at: this.now()
6558
+ };
6559
+ const next = await this.store.save(RoomSchema.parse({
6560
+ ...room,
6561
+ role_briefings: { ...room.role_briefings, [request.role]: briefing }
6562
+ }));
6563
+ if (next.state !== "active") return { room: next, redelivered: false };
6564
+ const holders = activeSeats(next).filter((seat) => seat.role === request.role);
6565
+ if (holders.length === 0) return { room: next, redelivered: false };
6566
+ await this.ensureBriefingKind(next, holders, {
6567
+ category: "role_briefing",
6568
+ briefing_role: request.role,
6569
+ text: briefing.text,
6570
+ briefing_version: briefing.version
6571
+ });
6572
+ return { room: next, redelivered: true };
6573
+ });
6574
+ if (redelivered) await this.intake.resumePending(id);
6575
+ return updated;
6576
+ }
6577
+ async deleteRoleBriefing(roomId, input) {
6578
+ const id = LowerCrockfordUlidSchema.parse(roomId);
6579
+ const request = RoleBriefingDeleteInputSchema.parse(input);
6580
+ return this.lock(id, async () => {
6581
+ const room = await this.store.load(id);
6582
+ this.assertMutable(room, "delete a role briefing for");
6583
+ if (room.role_briefings[request.role] === void 0) {
6584
+ throw new RoomServiceError(`no role briefing exists for "${request.role}" in room "${id}"`);
6585
+ }
6586
+ const { [request.role]: _removed, ...rest } = room.role_briefings;
6587
+ return this.store.save(RoomSchema.parse({ ...room, role_briefings: rest }));
6215
6588
  });
6216
6589
  }
6217
6590
  async createInvite(roomId, input) {
@@ -6220,33 +6593,215 @@ var init_service = __esm({
6220
6593
  return this.lock(id, async () => {
6221
6594
  const room = await this.store.load(id);
6222
6595
  this.assertMutable(room, "create an invite for");
6223
- const packet = this.packet(id);
6224
- const minted = await packet.mintInvite(request.mode);
6225
- const invite = RoomInviteSchema.parse({
6226
- invite_id: minted.invite_id,
6596
+ return this.mintInviteUnlocked(room, {
6227
6597
  mode: request.mode,
6228
- role: request.role,
6229
- min_accepts: request.min_accepts,
6230
- accepted_cids: [],
6231
- state: "live",
6232
- created_at: this.now()
6598
+ role: request.role ?? DEFAULT_ROLE,
6599
+ min_accepts: request.min_accepts
6233
6600
  });
6601
+ });
6602
+ }
6603
+ async mintInviteUnlocked(room, request) {
6604
+ const packet = this.packet(room.room_id);
6605
+ const minted = await packet.mintInvite(request.mode);
6606
+ const invite = RoomInviteSchema.parse({
6607
+ invite_id: minted.invite_id,
6608
+ mode: request.mode,
6609
+ role: request.role,
6610
+ min_accepts: request.min_accepts,
6611
+ accepted_cids: [],
6612
+ state: "live",
6613
+ created_at: this.now(),
6614
+ ...request.replaces_seat === void 0 ? {} : { replaces_seat: request.replaces_seat }
6615
+ });
6616
+ try {
6617
+ await this.store.save(RoomSchema.parse({ ...room, invites: [...room.invites, invite] }));
6618
+ } catch (error) {
6234
6619
  try {
6235
- await this.store.save(RoomSchema.parse({ ...room, invites: [...room.invites, invite] }));
6236
- } catch (error) {
6237
- try {
6238
- await packet.revokeInvite(minted.invite_id);
6239
- } catch {
6240
- }
6241
- throw error;
6620
+ await packet.revokeInvite(minted.invite_id);
6621
+ } catch {
6242
6622
  }
6243
- return {
6244
- room_id: id,
6245
- invite,
6246
- blob: minted.blob,
6247
- reusable: minted.reusable
6623
+ throw error;
6624
+ }
6625
+ return {
6626
+ room_id: room.room_id,
6627
+ invite,
6628
+ blob: minted.blob,
6629
+ reusable: minted.reusable
6630
+ };
6631
+ }
6632
+ /**
6633
+ * Operator-only removal (spec §5.2): archive-before-act membership intent,
6634
+ * seat state flip + epoch bump, core 0.13 bilateral sever with an honest
6635
+ * receipt, and an alias-form announcement unless the room or call is quiet.
6636
+ */
6637
+ async removeParticipant(roomId, input) {
6638
+ const id = LowerCrockfordUlidSchema.parse(roomId);
6639
+ const request = RemoveParticipantInputSchema.parse(input);
6640
+ const receipt = await this.lock(id, async () => {
6641
+ const room = await this.store.load(id);
6642
+ this.assertMutable(room, "remove a participant from");
6643
+ const seat = this.findActiveSeat(room, request.participant);
6644
+ const notify = (request.notify ?? true) && !room.quiet_membership;
6645
+ return this.beginRemovalUnlocked(room, seat, notify);
6646
+ });
6647
+ await this.intake.resumePending(id);
6648
+ return receipt;
6649
+ }
6650
+ /**
6651
+ * Removal plus a same-role invite stamped with the seat lineage (spec §5.3).
6652
+ * Owner override OC-2/OC-6: in an anonymous room the flow is unconditionally
6653
+ * silent — the successor inherits the alias and other members see nothing.
6654
+ */
6655
+ async replaceParticipant(roomId, input) {
6656
+ const id = LowerCrockfordUlidSchema.parse(roomId);
6657
+ const request = ReplaceParticipantInputSchema.parse(input);
6658
+ const receipt = await this.lock(id, async () => {
6659
+ const room = await this.store.load(id);
6660
+ this.assertMutable(room, "replace a participant in");
6661
+ const seat = this.findActiveSeat(room, request.participant);
6662
+ const notify = room.anonymous ? false : (request.notify ?? true) && !room.quiet_membership;
6663
+ const removal = await this.beginRemovalUnlocked(room, seat, notify);
6664
+ const current = await this.store.load(id);
6665
+ const invite = await this.mintInviteUnlocked(current, {
6666
+ mode: request.mode ?? "one_time",
6667
+ role: seat.role,
6668
+ min_accepts: request.min_accepts ?? 1,
6669
+ replaces_seat: seat.participant_id
6670
+ });
6671
+ return { ...invite, removal };
6672
+ });
6673
+ await this.intake.resumePending(id);
6674
+ return receipt;
6675
+ }
6676
+ findActiveSeat(room, participant) {
6677
+ const seat = room.seats.find((candidate) => candidate.state === "active" && (candidate.identity === participant || candidate.participant_id === participant));
6678
+ if (!seat) {
6679
+ throw new RoomServiceError(`"${participant}" is not an active participant of room "${room.room_id}"`);
6680
+ }
6681
+ return seat;
6682
+ }
6683
+ async beginRemovalUnlocked(room, seat, notify) {
6684
+ const intent = await this.store.append(room.room_id, {
6685
+ version: 1,
6686
+ kind: "membership_intent",
6687
+ room_id: room.room_id,
6688
+ at: this.now(),
6689
+ action: "remove",
6690
+ participant_id: seat.participant_id,
6691
+ recipient_identity: seat.identity,
6692
+ role: seat.role,
6693
+ // The participant-visible label: the alias in anonymous rooms, the
6694
+ // contact display name otherwise (INV-R3 holds either way).
6695
+ alias: seat.alias ?? seat.display_name,
6696
+ epoch: room.membership_epoch + 1,
6697
+ notify
6698
+ });
6699
+ if (intent.kind !== "membership_intent") {
6700
+ throw new RoomServiceError("storage returned the wrong membership intent kind");
6701
+ }
6702
+ const { receipt } = await this.completeRemovalUnlocked(room, intent);
6703
+ return receipt;
6704
+ }
6705
+ /**
6706
+ * Idempotent completion of a durable removal intent: each step re-checks the
6707
+ * archive/state it would produce, so a crash anywhere re-drives cleanly
6708
+ * (INV-R5; the 0.13 sever is replay-safe by design).
6709
+ */
6710
+ async completeRemovalUnlocked(room, intent) {
6711
+ let current = room;
6712
+ const index = current.seats.findIndex(
6713
+ (candidate) => candidate.participant_id === intent.participant_id
6714
+ );
6715
+ if (index < 0) {
6716
+ throw new RoomServiceError(
6717
+ `membership intent ${intent.record_id} references an unknown seat in room "${current.room_id}"`
6718
+ );
6719
+ }
6720
+ if (current.seats[index].state !== "removed") {
6721
+ const seats = [...current.seats];
6722
+ seats[index] = {
6723
+ ...seats[index],
6724
+ state: "removed",
6725
+ removed_at: intent.at,
6726
+ removed_epoch: intent.epoch
6248
6727
  };
6728
+ current = await this.store.save(RoomSchema.parse({
6729
+ ...current,
6730
+ seats,
6731
+ membership_epoch: Math.max(current.membership_epoch, intent.epoch)
6732
+ }));
6733
+ }
6734
+ const records = await this.store.read(current.room_id);
6735
+ const existing = records.find((record) => record.kind === "membership_result" && record.intent_record_id === intent.record_id);
6736
+ let outcome;
6737
+ if (existing !== void 0 && existing.kind === "membership_result") {
6738
+ outcome = {
6739
+ status: existing.status,
6740
+ notified: existing.notified,
6741
+ key_material_retained: true
6742
+ };
6743
+ } else {
6744
+ outcome = await this.packet(current.room_id).removeContact(intent.recipient_identity);
6745
+ const result = await this.store.append(current.room_id, {
6746
+ version: 1,
6747
+ kind: "membership_result",
6748
+ room_id: current.room_id,
6749
+ at: this.now(),
6750
+ intent_record_id: intent.record_id,
6751
+ participant_id: intent.participant_id,
6752
+ status: outcome.status,
6753
+ notified: outcome.notified,
6754
+ key_material_retained: true
6755
+ });
6756
+ if (result.kind !== "membership_result") {
6757
+ throw new RoomServiceError("storage returned the wrong membership result kind");
6758
+ }
6759
+ }
6760
+ if (intent.notify) await this.ensureMembershipNotice(current, intent);
6761
+ return {
6762
+ room: current,
6763
+ receipt: {
6764
+ room_id: current.room_id,
6765
+ participant_id: intent.participant_id,
6766
+ epoch: intent.epoch,
6767
+ status: outcome.status,
6768
+ notified: outcome.notified,
6769
+ key_material_retained: true
6770
+ }
6771
+ };
6772
+ }
6773
+ async ensureMembershipNotice(room, intent) {
6774
+ const records = await this.store.read(room.room_id);
6775
+ const already = records.some((record) => record.kind === "message" && record.category === "membership" && record.membership?.action === "remove" && record.membership.epoch === intent.epoch);
6776
+ if (already) return;
6777
+ const remaining = activeSeats(room);
6778
+ if (remaining.length === 0) return;
6779
+ const label = intent.alias ?? intent.role;
6780
+ const appended = await this.store.append(room.room_id, {
6781
+ version: 1,
6782
+ kind: "message",
6783
+ room_id: room.room_id,
6784
+ at: this.now(),
6785
+ message_id: LowerCrockfordUlidSchema.parse(this.nextMessageId()),
6786
+ author: { identity: room.identity_cid, display_name: room.identity_name, role: ROOM_ROLE },
6787
+ category: "membership",
6788
+ text: `${label} left the room \xB7 epoch ${intent.epoch}`,
6789
+ membership: { action: "remove", alias: label, role: intent.role, epoch: intent.epoch },
6790
+ recipient_identities: uniqueIdentities(remaining.map((seat) => seat.identity))
6249
6791
  });
6792
+ if (appended.kind !== "message") {
6793
+ throw new RoomServiceError("storage returned the wrong membership notice kind");
6794
+ }
6795
+ for (const recipientIdentity of appended.recipient_identities) {
6796
+ await this.store.append(room.room_id, {
6797
+ version: 1,
6798
+ kind: "relay_intent",
6799
+ room_id: room.room_id,
6800
+ at: this.now(),
6801
+ message_id: appended.message_id,
6802
+ recipient_identity: recipientIdentity
6803
+ });
6804
+ }
6250
6805
  }
6251
6806
  async revokeInvite(roomId, inviteId) {
6252
6807
  const id = LowerCrockfordUlidSchema.parse(roomId);
@@ -6426,8 +6981,26 @@ var init_service = __esm({
6426
6981
  }
6427
6982
  async history(roomId, options = {}) {
6428
6983
  const id = LowerCrockfordUlidSchema.parse(roomId);
6429
- const page = HistoryOptionsSchema.parse(options);
6430
- return this.store.read(id, page);
6984
+ const { view, ...page } = HistoryOptionsSchema.parse(options);
6985
+ const records = await this.store.read(id, page);
6986
+ if (view !== "participant") return records;
6987
+ return records.filter((record) => record.kind === "message").map((record) => {
6988
+ const {
6989
+ author_alias,
6990
+ recipient_identities: _recipients,
6991
+ source_msg_id: _sourceMsg,
6992
+ source_wire_id: _sourceWire,
6993
+ ...rest
6994
+ } = record;
6995
+ return {
6996
+ ...rest,
6997
+ author: author_alias === void 0 ? record.author : {
6998
+ identity: author_alias.participant_id,
6999
+ display_name: author_alias.alias,
7000
+ role: record.author.role
7001
+ }
7002
+ };
7003
+ });
6431
7004
  }
6432
7005
  /**
6433
7006
  * Forward-only close. Every external contact mutation is preceded by a
@@ -6492,7 +7065,7 @@ var init_service = __esm({
6492
7065
  },
6493
7066
  category: "chat",
6494
7067
  text: request.text,
6495
- recipient_identities: uniqueIdentities(room.seats.map((seat) => seat.identity))
7068
+ recipient_identities: uniqueIdentities(activeSeats(room).map((seat) => seat.identity))
6496
7069
  });
6497
7070
  if (appended.kind !== "message") throw new RoomServiceError("storage returned the wrong room message kind");
6498
7071
  for (const recipientIdentity of appended.recipient_identities) {
@@ -6517,7 +7090,15 @@ var init_service = __esm({
6517
7090
  }
6518
7091
  const origins = packet.listContactOrigins();
6519
7092
  const inviteById = new Map(room.invites.map((invite) => [invite.invite_id, invite]));
6520
- const existingCids = new Set(room.seats.map((seat) => seat.identity));
7093
+ const existingCids = new Set(room.seats.filter((seat) => seat.state === "active").map((seat) => seat.identity));
7094
+ const lastRemovedAt = /* @__PURE__ */ new Map();
7095
+ for (const seat of room.seats) {
7096
+ if (seat.state !== "removed" || seat.removed_at === void 0) continue;
7097
+ const previous = lastRemovedAt.get(seat.identity);
7098
+ if (previous === void 0 || seat.removed_at > previous) {
7099
+ lastRemovedAt.set(seat.identity, seat.removed_at);
7100
+ }
7101
+ }
6521
7102
  const newSeats = [];
6522
7103
  for (const [cid, displayName] of contactsByCid) {
6523
7104
  if (existingCids.has(cid)) continue;
@@ -6525,12 +7106,23 @@ var init_service = __esm({
6525
7106
  if (!origin || origin.via !== "invite_one_time" && origin.via !== "invite_public") continue;
6526
7107
  const invite = inviteById.get(origin.invite_id);
6527
7108
  if (!invite || invite.state === "receipt_pending" || invite.recovery_of !== void 0 && invite.recovery_confirmed !== true) continue;
7109
+ const removedAt = lastRemovedAt.get(cid);
7110
+ if (removedAt !== void 0 && invite.created_at <= removedAt) continue;
7111
+ const seated = [...room.seats, ...newSeats];
7112
+ const predecessor = invite.replaces_seat === void 0 ? void 0 : seated.find((seat) => seat.participant_id === invite.replaces_seat && seat.state === "removed");
7113
+ if (invite.replaces_seat !== void 0 && !predecessor) continue;
6528
7114
  newSeats.push({
6529
7115
  identity: cid,
6530
7116
  display_name: displayName,
6531
7117
  role: invite.role,
6532
7118
  invite_id: invite.invite_id,
6533
- accepted_at: origin.at
7119
+ accepted_at: origin.at,
7120
+ participant_id: LowerCrockfordUlidSchema.parse(generateUlid()),
7121
+ state: "active",
7122
+ ...predecessor === void 0 ? {} : { replaces_seat: predecessor.participant_id },
7123
+ // OC-6 (owner override): a replacement into a role inherits the
7124
+ // predecessor's alias — the alias binds to the seat/role lineage.
7125
+ ...room.anonymous ? { alias: predecessor?.alias ?? mintAlias(seated, invite.role) } : {}
6534
7126
  });
6535
7127
  existingCids.add(cid);
6536
7128
  }
@@ -6556,13 +7148,26 @@ var init_service = __esm({
6556
7148
  }
6557
7149
  return { ...invite, accepted_cids, state };
6558
7150
  });
6559
- let next = RoomSchema.parse({ ...room, seats, invites });
7151
+ let next = RoomSchema.parse({
7152
+ ...room,
7153
+ seats,
7154
+ invites,
7155
+ membership_epoch: room.membership_epoch + newSeats.length
7156
+ });
7157
+ const journal = await this.store.read(next.room_id);
7158
+ const completedIntents = new Set(journal.filter((record) => record.kind === "membership_result").map((record) => record.kind === "membership_result" ? record.intent_record_id : ""));
7159
+ for (const intent of journal.filter(
7160
+ (record) => record.kind === "membership_intent"
7161
+ )) {
7162
+ if (completedIntents.has(intent.record_id)) continue;
7163
+ ({ room: next } = await this.completeRemovalUnlocked(next, intent));
7164
+ }
6560
7165
  const requirementsMet = invites.filter((invite) => invite.state !== "revoked").every((invite) => invite.accepted_cids.length >= invite.min_accepts);
6561
7166
  if (next.state === "provisioning" && seats.length > 0 && requirementsMet) {
6562
- const activationAt = await this.ensureActivationBriefing(next, seats);
7167
+ const activationAt = await this.ensureActivationBriefing(next, activeSeats(next));
6563
7168
  next = RoomSchema.parse({ ...next, state: "active", activated_at: activationAt });
6564
- } else if (next.state === "active") {
6565
- for (const seat of newSeats) await this.ensureLateBriefing(next, seat);
7169
+ } else if (next.state === "active" && newSeats.length > 0) {
7170
+ await this.ensureActivationBriefing(next, newSeats);
6566
7171
  }
6567
7172
  return this.store.save(next);
6568
7173
  }
@@ -6659,62 +7264,98 @@ var init_service = __esm({
6659
7264
  deleteReceipt(roomId) {
6660
7265
  return { version: 1, room_id: roomId, deleted: true, scope: "this_host" };
6661
7266
  }
7267
+ /**
7268
+ * Deliver the common briefing followed by the seat's role briefing (spec
7269
+ * §3.3): exactly once per (seat, briefing kind, version) via the message +
7270
+ * relay-intent ledger. Returns the timestamp of the common briefing message
7271
+ * that admitted the earliest of the given recipients (activation time).
7272
+ */
6662
7273
  async ensureActivationBriefing(room, recipients) {
7274
+ const at = await this.ensureBriefingKind(room, recipients, {
7275
+ category: "briefing",
7276
+ text: room.mission.briefing,
7277
+ briefing_version: room.mission.briefing_version
7278
+ });
7279
+ await this.ensureRoleBriefings(room, recipients);
7280
+ return at;
7281
+ }
7282
+ async ensureRoleBriefings(room, recipients) {
7283
+ for (const seat of recipients) {
7284
+ const briefing = room.role_briefings[seat.role];
7285
+ if (!briefing) continue;
7286
+ await this.ensureBriefingKind(room, [seat], {
7287
+ category: "role_briefing",
7288
+ briefing_role: seat.role,
7289
+ text: briefing.text,
7290
+ briefing_version: briefing.version
7291
+ });
7292
+ }
7293
+ }
7294
+ /** Re-deliver the (just bumped) common briefing to every active seat. */
7295
+ async redeliverCommonBriefing(room) {
7296
+ await this.ensureBriefingKind(room, activeSeats(room), {
7297
+ category: "briefing",
7298
+ text: room.mission.briefing,
7299
+ briefing_version: room.mission.briefing_version
7300
+ });
7301
+ }
7302
+ async ensureBriefingKind(room, recipients, briefing) {
6663
7303
  const records = await this.store.read(room.room_id);
6664
- let message = records.find(
6665
- (record) => record.kind === "message" && record.category === "briefing"
6666
- );
6667
- if (!message) message = await this.appendBriefing(room, recipients);
6668
- const intents = new Set(records.filter((record) => record.kind === "relay_intent" && record.message_id === message.message_id).map((record) => record.kind === "relay_intent" ? record.recipient_identity : ""));
6669
- for (const recipientIdentity of message.recipient_identities) {
6670
- if (!intents.has(recipientIdentity)) {
7304
+ const matching = records.filter((record) => record.kind === "message" && record.category === briefing.category && record.briefing_role === briefing.briefing_role && (record.briefing_version ?? 1) === briefing.briefing_version);
7305
+ const intentsByMessage = /* @__PURE__ */ new Map();
7306
+ for (const record of records) {
7307
+ if (record.kind !== "relay_intent") continue;
7308
+ const intents = intentsByMessage.get(record.message_id) ?? /* @__PURE__ */ new Set();
7309
+ intents.add(record.recipient_identity);
7310
+ intentsByMessage.set(record.message_id, intents);
7311
+ }
7312
+ const covered = /* @__PURE__ */ new Set();
7313
+ for (const message of matching) {
7314
+ const intents = intentsByMessage.get(message.message_id) ?? /* @__PURE__ */ new Set();
7315
+ for (const recipientIdentity of message.recipient_identities) {
7316
+ covered.add(recipientIdentity);
7317
+ if (!intents.has(recipientIdentity)) {
7318
+ await this.store.append(room.room_id, {
7319
+ version: 1,
7320
+ kind: "relay_intent",
7321
+ room_id: room.room_id,
7322
+ at: this.now(),
7323
+ message_id: message.message_id,
7324
+ recipient_identity: recipientIdentity
7325
+ });
7326
+ }
7327
+ }
7328
+ }
7329
+ const missing = recipients.filter((seat) => !covered.has(seat.identity));
7330
+ let appendedAt;
7331
+ if (missing.length > 0) {
7332
+ const appended = await this.store.append(room.room_id, {
7333
+ version: 1,
7334
+ kind: "message",
7335
+ room_id: room.room_id,
7336
+ at: this.now(),
7337
+ message_id: LowerCrockfordUlidSchema.parse(this.nextMessageId()),
7338
+ author: { identity: room.identity_cid, display_name: room.identity_name, role: ROOM_ROLE },
7339
+ category: briefing.category,
7340
+ ...briefing.briefing_role === void 0 ? {} : { briefing_role: briefing.briefing_role },
7341
+ briefing_version: briefing.briefing_version,
7342
+ text: briefing.text,
7343
+ recipient_identities: uniqueIdentities(missing.map((seat) => seat.identity))
7344
+ });
7345
+ if (appended.kind !== "message") throw new RoomServiceError("storage returned the wrong briefing record kind");
7346
+ appendedAt = appended.at;
7347
+ for (const recipientIdentity of appended.recipient_identities) {
6671
7348
  await this.store.append(room.room_id, {
6672
7349
  version: 1,
6673
7350
  kind: "relay_intent",
6674
7351
  room_id: room.room_id,
6675
7352
  at: this.now(),
6676
- message_id: message.message_id,
7353
+ message_id: appended.message_id,
6677
7354
  recipient_identity: recipientIdentity
6678
7355
  });
6679
7356
  }
6680
7357
  }
6681
- const originalAudience = new Set(message.recipient_identities);
6682
- for (const recipient of recipients) {
6683
- if (!originalAudience.has(recipient.identity)) await this.ensureLateBriefing(room, recipient);
6684
- }
6685
- return message.at;
6686
- }
6687
- async ensureLateBriefing(room, seat) {
6688
- const records = await this.store.read(room.room_id);
6689
- let message = [...records].reverse().find(
6690
- (record) => record.kind === "message" && record.category === "briefing" && record.recipient_identities.includes(seat.identity)
6691
- );
6692
- if (!message) message = await this.appendBriefing(room, [seat]);
6693
- const alreadyBriefed = records.some((record) => record.kind === "relay_intent" && record.message_id === message.message_id && record.recipient_identity === seat.identity);
6694
- if (alreadyBriefed) return;
6695
- await this.store.append(room.room_id, {
6696
- version: 1,
6697
- kind: "relay_intent",
6698
- room_id: room.room_id,
6699
- at: this.now(),
6700
- message_id: message.message_id,
6701
- recipient_identity: seat.identity
6702
- });
6703
- }
6704
- async appendBriefing(room, recipients) {
6705
- const record = await this.store.append(room.room_id, {
6706
- version: 1,
6707
- kind: "message",
6708
- room_id: room.room_id,
6709
- at: this.now(),
6710
- message_id: LowerCrockfordUlidSchema.parse(this.nextMessageId()),
6711
- author: { identity: room.identity_cid, display_name: room.identity_name, role: ROOM_ROLE },
6712
- category: "briefing",
6713
- text: room.mission.briefing,
6714
- recipient_identities: uniqueIdentities(recipients.map((seat) => seat.identity))
6715
- });
6716
- if (record.kind !== "message") throw new RoomServiceError("storage returned the wrong briefing record kind");
6717
- return record;
7358
+ return matching[0]?.at ?? appendedAt ?? this.now();
6718
7359
  }
6719
7360
  lock(roomId, work) {
6720
7361
  return this.store.mutex(roomId).runExclusive(work);
@@ -6740,7 +7381,7 @@ var init_service = __esm({
6740
7381
  });
6741
7382
 
6742
7383
  // src/storage.ts
6743
- import { randomBytes as randomBytes4 } from "node:crypto";
7384
+ import { randomBytes as randomBytes3 } from "node:crypto";
6744
7385
  import * as nodeFs3 from "node:fs";
6745
7386
  import { AsyncLocalStorage } from "node:async_hooks";
6746
7387
  import { dirname as dirname4, join as join4 } from "node:path";
@@ -6749,6 +7390,7 @@ var init_storage = __esm({
6749
7390
  "src/storage.ts"() {
6750
7391
  "use strict";
6751
7392
  init_contracts();
7393
+ init_ulid();
6752
7394
  DIRECTORY_MODE2 = 448;
6753
7395
  FILE_MODE2 = 384;
6754
7396
  NO_FOLLOW2 = nodeFs3.constants.O_NOFOLLOW ?? 0;
@@ -6989,11 +7631,17 @@ var init_storage = __esm({
6989
7631
  `room "${validRoomId}" has archive residue without deletion metadata`
6990
7632
  );
6991
7633
  }
6992
- const expected = /* @__PURE__ */ new Set(["archive.jsonl", "room.json"]);
7634
+ const expected = /* @__PURE__ */ new Set(["archive.jsonl", "room.json", "room.json.v1.bak"]);
6993
7635
  const unexpected = this.fs.readdirSync(roomDir).filter((name) => !expected.has(name));
6994
7636
  if (unexpected.length > 0) {
6995
7637
  throw new CoworkStorageError(`room "${validRoomId}" contains live or unexpected residue: ${unexpected.join(", ")}`);
6996
7638
  }
7639
+ const backupPath = `${metadataPath}.v1.bak`;
7640
+ if (this.lstatIfPresent(backupPath)) {
7641
+ this.assertRegularFile(backupPath, "room metadata v1 backup");
7642
+ this.fs.unlinkSync(backupPath);
7643
+ this.fsyncDirectory(roomDir);
7644
+ }
6997
7645
  if (archivePresent) {
6998
7646
  this.assertRegularFile(archivePath, "room archive");
6999
7647
  this.fs.unlinkSync(archivePath);
@@ -7048,10 +7696,106 @@ var init_storage = __esm({
7048
7696
  } catch (error) {
7049
7697
  throw this.wrap(`malformed metadata for room "${roomId}"`, error);
7050
7698
  }
7051
- const room = RoomSchema.parse(decoded);
7699
+ const room = this.isVersion1(decoded) ? this.migrateUnlocked(roomId, decoded, bytes) : RoomSchema.parse(decoded);
7052
7700
  if (room.room_id !== roomId) throw new CoworkStorageError(`metadata room_id does not match room "${roomId}"`);
7053
7701
  return room;
7054
7702
  }
7703
+ isVersion1(decoded) {
7704
+ return typeof decoded === "object" && decoded !== null && decoded.version === 1;
7705
+ }
7706
+ /**
7707
+ * Lazy additive v1 → v2 migration (spec §7): preserve the exact pre-migration
7708
+ * bytes once as room.json.v1.bak, then atomically persist the v2 metadata.
7709
+ *
7710
+ * THE BACKUP IS WRITTEN TEMP → FSYNC → RENAME, not opened in place, and the
7711
+ * reason is a crash window that an existence check cannot see. The earlier
7712
+ * version guarded with `if (!lstatIfPresent(backupPath))` and wrote straight
7713
+ * into the final path under O_CREAT|O_EXCL. A crash inside that write leaves a
7714
+ * PARTIAL file that nonetheless EXISTS, so the next load's existence check
7715
+ * skips the backup, writes v2, and the pre-migration bytes are gone — no
7716
+ * error, no warning, and the one artefact that exists to undo a bad migration
7717
+ * is a truncated fragment. Measured before the fix: a 40-byte prefix of a
7718
+ * 604-byte room, JSON.parse false, room.json already v2.
7719
+ *
7720
+ * A rename is atomic, so the final path now only ever appears complete. The
7721
+ * temp file is created with O_EXCL under a pid+random name and removed on
7722
+ * failure, so a crashed attempt leaves at most an orphan temp, never a
7723
+ * plausible-looking backup.
7724
+ *
7725
+ * AND AN EXISTING BACKUP IS PARSED BEFORE IT IS TRUSTED, which repairs the
7726
+ * case where a partial file is ALREADY on disk from a build without this fix —
7727
+ * exactly the state a host that ran the previous code could be in right now.
7728
+ * A backup that does not parse as v1 is replaced by the bytes we hold, because
7729
+ * those are the real pre-migration bytes and the fragment is worthless.
7730
+ *
7731
+ * WHAT THE BACKUP IS NOT: restoring it is NOT a rollback. See the note on
7732
+ * `restoreV1Backup` — re-migrating mints fresh participant ids.
7733
+ */
7734
+ migrateUnlocked(roomId, decoded, originalBytes) {
7735
+ const v1 = RoomV1Schema.parse(decoded);
7736
+ if (v1.room_id !== roomId) throw new CoworkStorageError(`metadata room_id does not match room "${roomId}"`);
7737
+ const migrated = migrateRoomV1(v1, generateUlid);
7738
+ const backupPath = `${this.metadataPath(roomId)}.v1.bak`;
7739
+ if (!this.hasIntactV1Backup(backupPath)) {
7740
+ this.atomicBytesWrite(backupPath, originalBytes, "room metadata v1 backup");
7741
+ }
7742
+ this.atomicMetadataWrite(this.metadataPath(roomId), migrated);
7743
+ return migrated;
7744
+ }
7745
+ /**
7746
+ * Is there already a backup we would be willing to hand back to an operator?
7747
+ *
7748
+ * Existence is not the question — a partial file exists. It must parse and
7749
+ * still claim to be the v1 metadata for this room; anything else is a fragment
7750
+ * and is better overwritten with the bytes we are holding right now.
7751
+ */
7752
+ hasIntactV1Backup(backupPath) {
7753
+ if (!this.lstatIfPresent(backupPath)) return false;
7754
+ try {
7755
+ const decoded = JSON.parse(utf8Decoder.decode(this.readFileNoFollow(backupPath, "room metadata v1 backup")));
7756
+ return this.isVersion1(decoded);
7757
+ } catch {
7758
+ return false;
7759
+ }
7760
+ }
7761
+ /**
7762
+ * Durably place exact bytes at `path`: temp under O_EXCL, fsync, rename, fsync
7763
+ * the directory. The same dance as atomicMetadataWrite, which serialises a Room
7764
+ * rather than taking bytes verbatim — and taking them verbatim is the whole
7765
+ * point for a backup, whose value is being byte-identical to what was there.
7766
+ */
7767
+ atomicBytesWrite(path, bytes, label) {
7768
+ if (this.lstatIfPresent(path)) this.assertRegularFile(path, label);
7769
+ const temp = `${path}.tmp-${process.pid}-${randomBytes3(8).toString("hex")}`;
7770
+ let fd;
7771
+ try {
7772
+ fd = this.fs.openSync(
7773
+ temp,
7774
+ nodeFs3.constants.O_CREAT | nodeFs3.constants.O_EXCL | nodeFs3.constants.O_WRONLY | NO_FOLLOW2,
7775
+ FILE_MODE2
7776
+ );
7777
+ this.validateOpenPath(fd, temp, `temporary ${label}`, "file", true);
7778
+ this.fs.fchmodSync(fd, FILE_MODE2);
7779
+ this.writeAll(fd, bytes);
7780
+ this.fs.fsyncSync(fd);
7781
+ this.fs.closeSync(fd);
7782
+ fd = void 0;
7783
+ this.fs.renameSync(temp, path);
7784
+ this.fsyncDirectory(dirname4(path));
7785
+ } catch (error) {
7786
+ if (fd !== void 0) {
7787
+ try {
7788
+ this.fs.closeSync(fd);
7789
+ } catch {
7790
+ }
7791
+ }
7792
+ try {
7793
+ this.fs.rmSync(temp, { force: true });
7794
+ } catch {
7795
+ }
7796
+ throw this.wrap(`failed to write ${label} at ${path}`, error);
7797
+ }
7798
+ }
7055
7799
  scanArchive(roomId) {
7056
7800
  const path = this.archivePath(roomId);
7057
7801
  this.assertRegularFile(path, "room archive");
@@ -7213,7 +7957,7 @@ var init_storage = __esm({
7213
7957
  }
7214
7958
  atomicMetadataWrite(path, room) {
7215
7959
  if (this.lstatIfPresent(path)) this.assertRegularFile(path, "room metadata");
7216
- const temp = `${path}.tmp-${process.pid}-${randomBytes4(8).toString("hex")}`;
7960
+ const temp = `${path}.tmp-${process.pid}-${randomBytes3(8).toString("hex")}`;
7217
7961
  const bytes = Buffer.from(`${JSON.stringify(room)}
7218
7962
  `, "utf8");
7219
7963
  let fd;
@@ -7456,7 +8200,7 @@ var init_web = __esm({
7456
8200
  });
7457
8201
 
7458
8202
  // src/transports.ts
7459
- import { randomBytes as randomBytes5 } from "node:crypto";
8203
+ import { randomBytes as randomBytes4 } from "node:crypto";
7460
8204
  import * as http from "node:http";
7461
8205
  import * as net from "node:net";
7462
8206
  import * as nodeFs5 from "node:fs";
@@ -7469,19 +8213,36 @@ function createServiceRoutes(service) {
7469
8213
  room_id: external_exports.string(),
7470
8214
  goal: external_exports.unknown().optional(),
7471
8215
  briefing: external_exports.unknown().optional(),
7472
- status: external_exports.unknown().optional()
8216
+ status: external_exports.unknown().optional(),
8217
+ quiet_membership: external_exports.unknown().optional()
7473
8218
  }).strict().parse(params);
7474
8219
  return service.updateRoom(room_id, input);
7475
8220
  } },
8221
+ "room.briefing.role.set": { auth: true, run: (params) => {
8222
+ const { room_id, ...input } = RoleBriefingSetParams.parse(params);
8223
+ return service.setRoleBriefing(room_id, input);
8224
+ } },
8225
+ "room.briefing.role.delete": { auth: true, run: (params) => {
8226
+ const { room_id, ...input } = RoleBriefingDeleteParams.parse(params);
8227
+ return service.deleteRoleBriefing(room_id, input);
8228
+ } },
7476
8229
  "room.invite": { auth: true, run: (params) => {
7477
8230
  const { room_id, ...input } = external_exports.object({
7478
8231
  room_id: external_exports.string(),
7479
8232
  mode: external_exports.unknown(),
7480
- role: external_exports.unknown(),
8233
+ role: external_exports.unknown().optional(),
7481
8234
  min_accepts: external_exports.unknown()
7482
8235
  }).strict().parse(params);
7483
8236
  return service.createInvite(room_id, input);
7484
8237
  } },
8238
+ "room.participant.remove": { auth: true, run: (params) => {
8239
+ const { room_id, ...input } = ParticipantRemoveParams.parse(params);
8240
+ return service.removeParticipant(room_id, input);
8241
+ } },
8242
+ "room.participant.replace": { auth: true, run: (params) => {
8243
+ const { room_id, ...input } = ParticipantReplaceParams.parse(params);
8244
+ return service.replaceParticipant(room_id, input);
8245
+ } },
7485
8246
  "room.revoke": { auth: true, run: (params) => {
7486
8247
  const value = InviteRevokeParams.parse(params);
7487
8248
  return service.revokeInvite(value.room_id, value.invite_id);
@@ -7639,7 +8400,7 @@ function responseFinished2(response) {
7639
8400
  response.once("error", rejectResponse);
7640
8401
  });
7641
8402
  }
7642
- var MAX_REQUEST_BYTES, HTTP_HEADERS_TIMEOUT_MS, HTTP_REQUEST_TIMEOUT_MS, HTTP_BODY_IDLE_TIMEOUT_MS, HTTP_KEEP_ALIVE_TIMEOUT_MS, MAX_STALE_PRIVATE_SOCKET_CLEANUP, RpcIdSchema, RpcRequestSchema, RpcDispatcher, RoomIdParams, InviteRevokeParams, RecoverConfirmParams, HistoryParams, TransportServer;
8403
+ var MAX_REQUEST_BYTES, HTTP_HEADERS_TIMEOUT_MS, HTTP_REQUEST_TIMEOUT_MS, HTTP_BODY_IDLE_TIMEOUT_MS, HTTP_KEEP_ALIVE_TIMEOUT_MS, MAX_STALE_PRIVATE_SOCKET_CLEANUP, RpcIdSchema, RpcRequestSchema, RpcDispatcher, RoomIdParams, InviteRevokeParams, RecoverConfirmParams, HistoryParams, RoleBriefingSetParams, RoleBriefingDeleteParams, ParticipantRemoveParams, ParticipantReplaceParams, TransportServer;
7643
8404
  var init_transports = __esm({
7644
8405
  "src/transports.ts"() {
7645
8406
  "use strict";
@@ -7714,7 +8475,29 @@ var init_transports = __esm({
7714
8475
  HistoryParams = external_exports.object({
7715
8476
  room_id: external_exports.string(),
7716
8477
  after: external_exports.number().optional(),
7717
- limit: external_exports.number().optional()
8478
+ limit: external_exports.number().optional(),
8479
+ view: external_exports.enum(["operator", "participant"]).optional()
8480
+ }).strict();
8481
+ RoleBriefingSetParams = external_exports.object({
8482
+ room_id: external_exports.string(),
8483
+ role: external_exports.string(),
8484
+ text: external_exports.string()
8485
+ }).strict();
8486
+ RoleBriefingDeleteParams = external_exports.object({
8487
+ room_id: external_exports.string(),
8488
+ role: external_exports.string()
8489
+ }).strict();
8490
+ ParticipantRemoveParams = external_exports.object({
8491
+ room_id: external_exports.string(),
8492
+ participant: external_exports.string(),
8493
+ notify: external_exports.boolean().optional()
8494
+ }).strict();
8495
+ ParticipantReplaceParams = external_exports.object({
8496
+ room_id: external_exports.string(),
8497
+ participant: external_exports.string(),
8498
+ notify: external_exports.boolean().optional(),
8499
+ mode: external_exports.enum(["one_time", "public"]).optional(),
8500
+ min_accepts: external_exports.number().optional()
7718
8501
  }).strict();
7719
8502
  TransportServer = class {
7720
8503
  options;
@@ -7751,7 +8534,7 @@ var init_transports = __esm({
7751
8534
  await this.cleanupStalePrivateSockets();
7752
8535
  const unix = net.createServer((socket) => this.handleUnix(socket));
7753
8536
  this.unixServer = unix;
7754
- const privateSocketPath = `${this.options.socketPath}.private-${process.pid}-${randomBytes5(6).toString("hex")}`;
8537
+ const privateSocketPath = `${this.options.socketPath}.private-${process.pid}-${randomBytes4(6).toString("hex")}`;
7755
8538
  this.privateSocketPath = privateSocketPath;
7756
8539
  try {
7757
8540
  await listen(unix, privateSocketPath);
@@ -8060,7 +8843,7 @@ var init_transports = __esm({
8060
8843
  }
8061
8844
  }
8062
8845
  quarantinePath(target, label) {
8063
- const path = `${this.options.socketPath}.safe-residue-${label}-${process.pid}-${randomBytes5(6).toString("hex")}`;
8846
+ const path = `${this.options.socketPath}.safe-residue-${label}-${process.pid}-${randomBytes4(6).toString("hex")}`;
8064
8847
  this.fs.renameSync(target, path);
8065
8848
  const moved = this.fs.lstatSync(path);
8066
8849
  return { target, path, dev: moved.dev, ino: moved.ino };
@@ -8084,7 +8867,7 @@ var init_transports = __esm({
8084
8867
  }
8085
8868
  }
8086
8869
  quarantinePublicPath() {
8087
- const quarantinePath = `${this.options.socketPath}.replacement-${process.pid}-${randomBytes5(6).toString("hex")}`;
8870
+ const quarantinePath = `${this.options.socketPath}.replacement-${process.pid}-${randomBytes4(6).toString("hex")}`;
8088
8871
  try {
8089
8872
  this.fs.renameSync(this.options.socketPath, quarantinePath);
8090
8873
  } catch (error) {
@@ -8608,7 +9391,7 @@ var daemon_process_exports = {};
8608
9391
  __export(daemon_process_exports, {
8609
9392
  runDaemonProcess: () => runDaemonProcess
8610
9393
  });
8611
- import { randomBytes as randomBytes6 } from "node:crypto";
9394
+ import { randomBytes as randomBytes5 } from "node:crypto";
8612
9395
  async function runDaemonProcess() {
8613
9396
  try {
8614
9397
  const code = process.env.OURS_COWORK_DAEMON_WORKER === "1" ? await runWorker() : await runSupervisor();
@@ -8721,7 +9504,7 @@ async function runWorker() {
8721
9504
  },
8722
9505
  control: {
8723
9506
  // Created only after the supervisor capability handshake completed.
8724
- session: randomBytes6(16).toString("hex"),
9507
+ session: randomBytes5(16).toString("hex"),
8725
9508
  async requestSupervisorShutdown() {
8726
9509
  if (!capability || disconnected || process.connected === false) return false;
8727
9510
  return sendIpc({ type: "shutdown_request", capability });
@@ -8799,7 +9582,7 @@ var init_daemon_process = __esm({
8799
9582
 
8800
9583
  // src/daemon.ts
8801
9584
  import { fork } from "node:child_process";
8802
- import { randomBytes as randomBytes7 } from "node:crypto";
9585
+ import { randomBytes as randomBytes6 } from "node:crypto";
8803
9586
  import { resolve as resolve3 } from "node:path";
8804
9587
  import { fileURLToPath as fileURLToPath3 } from "node:url";
8805
9588
  async function runSupervisor(options = {}) {
@@ -8906,7 +9689,7 @@ var init_daemon = __esm({
8906
9689
  this.signals = options.signals ?? process;
8907
9690
  this.shutdownTimeoutMs = options.shutdownTimeoutMs ?? DAEMON_SHUTDOWN_TIMEOUT_MS;
8908
9691
  this.onStageCallback = options.onStage;
8909
- this.capability = options.capability ?? randomBytes7(32).toString("hex");
9692
+ this.capability = options.capability ?? randomBytes6(32).toString("hex");
8910
9693
  if (!/^[0-9a-f]{64}$/.test(this.capability)) throw new Error("invalid daemon worker capability");
8911
9694
  this.done = new Promise((resolveDone) => {
8912
9695
  this.resolveDone = resolveDone;