Part 3 · Chapter 3.11
You will produce: Bot users carrying a description the database requires, a sender required on every message and enforced by the compiler rather than a test, an application credential that may speak as software and not as any person, refusals that reveal nothing about who exists, and a bot that is billed as an active user while being exempt from the ceiling that refuses sends · about 80 minutes including the exercise
A customer's server posts a build result into a channel. It authenticates with an API key,
because it is a server and has no person behind it, and the message lands with user_id null.
For fourteen chapters that was fine. Nothing read the sender.
Then the channel-control chapter made the sender decide whether a private channel is visible, the user-surface chapter made it decide what a channel listing renders, and the resume path made it decide whether a message can be delivered at all. A row with no sender became a row those three chapters cannot describe — and there are 19,818 of them in the test lane, out of 83,806.
This chapter gives that message a sender. Most of the work is not the sender.
The plan for this chapter said the SRS had no concept of a message's sender and would need an amendment. Thirteen analysis passes read that sentence. Then one of them opened §4.5 and read the clauses instead of the identifiers:
| FR-MSG-13 | The system shall support sending a message on behalf of any user via
API key, for backend-originated messages. | P2 | T |P2, verification by test, on the books since v1.
The capability had been required for as long as the document existed. The outbox chapter satisfied it
by sending unattributed — and messages.controller.ts has recorded that reading ever since, in
a comment a reader would take at face value: "A tenant's own server sending on a customer's
behalf is FR-MSG-13, not a mistake."
So the amendment is two new clauses and two edits to clauses that already existed — which matters, because an additive-only amendment would have left this document asserting both that a key may send as anyone and that it may send only as a bot:
+ FR-USR-07 NEW Customers shall be able to create bot users representing their
own software, each carrying a description of what it is and why
it posts. A bot user shall not authenticate. P2 T
~ FR-MSG-13 AMENDED "on behalf of any user" -> "on behalf of a bot user of that
tenant" P2 T
+ FR-MSG-15 NEW Every message shall carry a sender. A message accepted with no
sender shall not be created. P1 T
~ FR-RTL-05 AMENDED quota on "unique active users" -> "unique active persons" P3 TThe sender a key names has to be something. Three candidates, and the third is the one:
flowchart TB
q["a key send has to name SOMETHING"]
q --> n["NULLABLE SENDER<br/>keep the absence"]
q --> s["SYNTHETIC USER<br/>the platform mints one"]
q --> b["BOT USER<br/>the customer declares one"]
n --> nc["every reader handles null<br/>— three chapters already pay for this<br/>toFrame drops the row entirely"]
s --> sc["chapter 3.10 argued against it:<br/>inflates the dimension the customer<br/>is measured on"]
b --> bc["a users row with kind and description<br/>every reader since 3.15 already reads users<br/>the description makes it ANSWERABLE"]
style nc fill:#7f1d1d,color:#fff,stroke:#dc2626
style sc fill:#7f1d1d,color:#fff,stroke:#dc2626
style bc fill:#064e3b,color:#fff,stroke:#059669A nullable sender keeps the absence and asks every reader to handle it — which is what already exists and what the last three chapters have been paying for. A synthetic user the platform mints is the one that costs a customer money without being asked for: usage is billed per active user, and a platform that invents identities puts its own inventions on somebody else's invoice. The quota chapter will meter exactly this dimension, which is why the decision belongs here rather than there — a metered thing must be a declared thing first. A bot user the customer creates, names and describes is a declared identity, and the difference from a synthetic one is who decided it existed.
The description is not decoration. It is what makes the sender answerable: a customer's support tooling can say what posted and why. A bot without one is the anonymous sender this chapter exists to remove, so the database refuses it:
@@ -215,19 +215,62 @@ export const users = pgTable(
//
// So the row survives with its profile fields cleared, and this marker is what says
// the row is deleted. `(environment_id, external_id)` stays unique, which is why
// presenting the same external id again reuses this row and clears the marker
// (FR-030) rather than creating a second identity.
deletedAt: timestamp("deleted_at", { withTimezone: true }),
+ // WHAT KIND OF THING THIS USER IS (FR-USR-07).
+ //
+ // A stored property on the row a customer already knows about, not a second table.
+ // Every reader built since the channel-control chapter reads `users`; a `bots` table would have
+ // needed each of them taught a second place to look, and a message's `user_id`
+ // would have had to reference one of two tables.
+ //
+ // `NOT NULL DEFAULT 'person'` is metadata-only on Postgres 11+, so the existing
+ // rows are not rewritten — the user-surface chapter measured that for `last_activity_at`.
+ // The default belongs HERE, at creation, and NOT in the request schema: a schema
+ // default would make "absent" indistinguishable from "person" before anything can
+ // compare it to the stored row, and telling those two apart is what makes a
+ // promotion reportable (FR-002b).
+ kind: text("kind").notNull().default("person"),
+ // WHAT THE SOFTWARE IS, AND WHY IT POSTS (FR-USR-07).
+ //
+ // NOT PROFILE DATA, and `deleteUser` must not clear it (FR-004a). FR-027 clears
+ // `display_name`, `avatar_url` and `metadata` on deletion; clearing this one would
+ // violate `users_bot_description_check` below and make a bot the one kind of user
+ // that cannot be deleted.
+ description: text("description"),
},
(t) => [
unique("users_environment_id_external_id_unique").on(
t.environmentId,
t.externalId,
+ ), // DR-02
+ // THE CONSTRAINED TEXT COLUMNS IN THIS SCHEMA NAME EACH OTHER (THE CHANNEL-CONTROL CHAPTER'S
+ // practice, applied here): `channels_type_check` on `channels.type`,
+ // `members_role_check` on `members.role`, `memberships_role_check` on an
+ // organisation membership's role, and this pair. One word apart is how `admin`
+ // nearly reached a channel member, so each of these says where its siblings are.
+ //
+ // AND `environments.kind` IS THE ONE THAT IS NOT CONSTRAINED. It has held
+ // `development` or `production` since chapter 2.1 (FR-TEN-04) with no CHECK, so
+ // this schema now has two columns called `kind` and only one of them cannot hold
+ // a typo. Named here rather than fixed: adding a constraint to a column
+ // seventeen chapters old is not this chapter's change, and leaving the asymmetry
+ // unmentioned is how the next reader assumes both are guarded.
+ check("users_kind_check", sql`${t.kind} IN ('person','bot')`),
+ // THE SECOND CHECK IS THE REQUIREMENT, not a nicety. It makes a bot without a
+ // description **unrepresentable** rather than merely refused: zod refuses one at
+ // the boundary (FR-002, FR-004b) and this refuses one from any writer, including
+ // a migration, a backfill, or a psql session. A description is what turns an
+ // opaque sender into an answerable one, so a bot without one is not a bot.
+ check(
+ "users_bot_description_check",
+ sql`${t.kind} <> 'bot' OR ${t.description} IS NOT NULL`,
),
- ], // DR-02
+ ],
);
export const channels = pgTable(
"channels",
{
id: uuid("id").primaryKey(),
@@ -394,12 +437,23 @@ export const readPositions = pgTable(
// The last sequence this user has read. Advances forwards only: a write naming a
// lower value is accepted and changes nothing, so a client replaying an old
// acknowledgement cannot move the count backwards. A value past
// `channels.last_sequence` is refused (FR-018) — a position nothing can reach makes
// every later count wrong.
sequence: bigint("sequence", { mode: "number" }).notNull(),
+ // WRITTEN BY EVERY POSITION WRITE AND READ BY NOTHING, and that is a decision rather
+ // than an oversight (the user-surface chapter's `gaps.md` §5).
+ //
+ // The channel-control and user-surface chapters exist because five columns had no
+ // reader, so leaving a sixth
+ // behind needs a sentence or it becomes the next feature's finding. The two options
+ // were a reader — an operations view answering "when did this user last catch up" —
+ // or a migration dropping it. Kept, on the expectation that the reader arrives.
+ //
+ // A column nobody chose to keep and a column somebody chose to keep look identical in
+ // a schema. This comment is the only thing that tells them apart.
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => [
primaryKey({ columns: [t.channelId, t.userId] }),-- The sender a message never had.
--
-- FR-MSG-13 has said since v1 that the system "shall support sending a message on
-- behalf of a bot user of that tenant via API key" — and until this chapter it read
-- "on behalf of any user", satisfied by naming nobody. The outbox chapter decided that when
-- nothing read the sender. Three chapters since have made the sender decide what is
-- rendered, what is delivered and what may be seen, so a message with no sender became
-- a row those three chapters cannot describe.
--
-- TWO COLUMNS, NOT A SECOND TABLE. `users` is what every reader built since the
-- channel-control chapter already reads. A `bots` table would have meant teaching each of them a second
-- place to look, and `messages.user_id` would have had to reference one of two tables.
--
-- NO BACKFILL, and that is measured rather than assumed. `ADD COLUMN ... NOT NULL
-- DEFAULT` is metadata-only on Postgres 11+ — the existing rows are not rewritten —
-- which the user-surface chapter measured for `last_activity_at` on the same table.
ALTER TABLE users
ADD COLUMN kind TEXT NOT NULL DEFAULT 'person',
ADD COLUMN description TEXT;
-- THE FIRST CHECK IS THE VOCABULARY. `channels_type_check` guards `channels.type`,
-- `members_role_check` guards a channel member's role and `memberships_role_check` an
-- organisation member's — one word apart is how `admin` nearly reached a channel
-- member, so each of these constraints names its siblings.
--
-- `environments.kind` is the one column called `kind` that has no CHECK: it has held
-- 'development' or 'production' since chapter 2.1 (FR-TEN-04) and cannot refuse a typo.
-- Not fixed here, because a constraint on a column seventeen chapters old is not this
-- chapter's change, and an unmentioned asymmetry is one the next reader assumes away.
ALTER TABLE users
ADD CONSTRAINT users_kind_check CHECK (kind IN ('person','bot'));
-- THE SECOND CHECK IS THE REQUIREMENT, not a nicety.
--
-- It makes a bot without a description UNREPRESENTABLE rather than merely refused. Zod
-- refuses one at the boundary (FR-002, FR-004b) and this refuses one from any writer —
-- a migration, a backfill, a psql session. A description is what turns an opaque sender
-- into an answerable one: a customer's support tooling can say what posted and why, and
-- a bot without that is the anonymous sender this chapter exists to remove.
--
-- IT ALSO DECIDES WHAT DELETION MAY DO. FR-027 clears `display_name`, `avatar_url` and
-- `metadata` when a user is deleted; clearing `description` too would violate this
-- constraint and make a bot the one kind of user that cannot be deleted. So a bot's
-- description is not profile data (FR-004a), and this line is why.
ALTER TABLE users
ADD CONSTRAINT users_bot_description_check
CHECK (kind <> 'bot' OR description IS NOT NULL);The migration was measured rather than assumed, and the answer split in a way the plan did not anticipate:
users rows on this machine 79,149
ADD COLUMN kind NOT NULL DEFAULT 'person', ADD COLUMN description 3.407 ms
ADD CONSTRAINT users_kind_check 5.398 ms
ADD CONSTRAINT users_bot_description_check 3.190 ms"No backfill needed" is true of the columns — 3.4 ms against 79,149 rows is metadata-only, as
Postgres 11 promised and the user-surface chapter measured for last_activity_at. It is not
true of the CHECKs: each validates every existing row. At this size that is nothing; the shape is
O(n), and "the migration needs no backfill" is the sentence somebody will quote.
Those four numbers were taken on the lane this chapter was built on, inside a transaction that rolled back, so the timing is of the real table and not of a copy of it. Read them as a shape and never as a budget — three milliseconds says the columns are metadata-only at eighty thousand rows and says nothing whatever about a customer's table at eighty million, where the CHECK is the one of the three that still scans.
The rule is that every message has a sender. The mechanism is one character:
@@ -581,12 +581,21 @@ export interface UserRow {
*
* Every route that names a user in its path reads this and answers 404. It is
* selected here rather than filtered in the query so a caller can tell the two
* apart: a repository that hid deleted rows would make the marker unobservable and
* the deletion untestable. */
deleted_at: string | null;
+ /** FR-USR-07. What kind of thing this user is — `'person'` or `'bot'`.
+ *
+ * ON EVERY USER, not only bots. A reader that had to infer personhood from a null
+ * description would be inferring it from the absence of something, and FR-003 asks
+ * for a stored property rather than an inference. */
+ kind: "person" | "bot";
+ /** What the software is, and why it posts. Null for a person, and
+ * `users_bot_description_check` makes it non-null for a bot at the database. */
+ description: string | null;
}
export interface ChannelRow {
id: string;
external_id: string;
/** The column has been `"public" | "private"` with a CHECK constraint since
@@ -657,12 +666,25 @@ export class ChannelArchivedError extends Error {
/** A write or a connect refused because the user is banned (FR-031).
*
* FIRST IN FR-021a's ORDER, and the ban check runs BEFORE the channel is read at all —
* so a banned user gets one answer for every channel id, whether it exists, belongs to
* somebody else, or was invented. Any other position leaks: check the channel first and
* a banned user learns which channel ids are real. */
+/** An application credential named a person (FR-007, FR-007a).
+ *
+ * ITS OWN CLASS, NOT A `ChannelNotFoundError`, because the two say different things and
+ * the service maps them to different codes. Carries the sender's INTERNAL id and never
+ * the customer's identifier: the message on the wire names neither the person asked for
+ * nor the bots that would have been accepted (SC-005). */
+export class SenderNotPermittedError extends Error {
+ constructor(readonly userId: string) {
+ super("an application credential may send only as a bot user");
+ this.name = "SenderNotPermittedError";
+ }
+}
+
export class UserBannedError extends Error {
constructor(public readonly userId: string) {
super(`user banned: ${userId}`);
this.name = "UserBannedError";
}
}
@@ -705,12 +727,19 @@ export class Repository {
external_id: externalId,
display_name: displayName ?? null,
avatar_url: null,
metadata: {},
banned_at: null,
deleted_at: null,
+ // `createUser` CANNOT MAKE A BOT, and that is deliberate. Its
+ // callers are the member-add and the token mint, where an unknown identifier
+ // arrives with nothing but a name; a bot needs a description, so it is created
+ // through the upsert where one can be supplied. This is also why `person -> bot`
+ // has an escape at all — see `upsertUser`.
+ kind: "person",
+ description: null,
};
}
const existing = await this.getUserByExternalId(externalId);
if (existing === null) throw new Error(`user ${externalId} could not be created or read`);
// The DISPLAY NAME OF THE EXISTING ROW WINS. A second call is not an update:
// FR-CHN-04 asks for membership, and quietly renaming a user because someone
@@ -725,12 +754,14 @@ export class Repository {
external_id: users.externalId,
display_name: users.displayName,
avatar_url: users.avatarUrl,
metadata: users.metadata,
bannedAt: users.bannedAt,
deletedAt: users.deletedAt,
+ kind: users.kind,
+ description: users.description,
})
.from(users)
.where(
and(
eq(users.environmentId, this.environmentId),
eq(users.externalId, externalId),
@@ -748,12 +779,17 @@ export class Repository {
// never hands back null — and the isolation harness removed `addMember`'s
// `(inserted.rowCount ?? 0)` for exactly this reason: an arm nothing can take,
// bought for nothing, in the one file constitution VI asks 100% of.
metadata: row.metadata as Record<string, unknown>,
banned_at: row.bannedAt === null ? null : toIso(row.bannedAt),
deleted_at: row.deletedAt === null ? null : toIso(row.deletedAt),
+ // `as` for the same reason as `metadata` above: the column is
+ // `notNull().default('person')` and `users_kind_check` bounds it to two
+ // values, so a `?? "person"` here would be an arm the database cannot produce.
+ kind: row.kind as "person" | "bot",
+ description: row.description,
};
}
/** IDEMPOTENT ON THE CUSTOMER'S OWN IDENTIFIER (FR-017, FR-CHN-02).
*
* This was a plain insert until the endpoint over it, which is fine for a fixture and
@@ -878,13 +914,22 @@ export class Repository {
SELECT c.id, u.id, ${role} FROM channels c, users u
WHERE c.id = ${channelId} AND c.environment_id = ${this.environmentId}
AND u.id = ${userId} AND u.environment_id = ${this.environmentId}
ON CONFLICT (channel_id, user_id) DO NOTHING
RETURNING channel_id`,
);
- if ((inserted.rowCount ?? 0) > 0) return "added";
+ // `RETURNING` and `.rows.length`, not `rowCount ?? 0`. `rowCount` is typed
+ // `number | null` by the driver and is never null for an INSERT, so the `??`
+ // was a branch nothing could take — one uncovered arm in the file constitution
+ // VI asks for 100% of, bought for nothing. A row that came back is a row that
+ // was inserted.
+ //
+ // BOTH SQL BRANCHES ALREADY RETURNED. The comment beside `metadata` a few
+ // hundred lines above has raised this objection since the user-surface
+ // chapter; what was missing was the line that acts on it.
+ if (inserted.rows.length > 0) return "added";
const existing = await this.db
.select({ userId: members.userId })
.from(members)
.innerJoin(channels, eq(channels.id, members.channelId))
.where(
@@ -1140,24 +1185,42 @@ export class Repository {
async upsertUser(
externalId: string,
profile: {
display_name?: string | null | undefined;
avatar_url?: string | null | undefined;
metadata?: Record<string, unknown> | undefined;
+ /** (FR-002b). ABSENT MEANS "NO CHANGE", NOT "PERSON" — the column
+ * default handles a new row and this method must not apply it to an existing
+ * one, or an entry updating a bot's description would silently demote it. */
+ kind?: "person" | "bot" | undefined;
+ description?: string | undefined;
},
- ): Promise<{ user: UserRow; status: "created" | "updated" | "revived" }> {
+ ): Promise<{
+ user: UserRow;
+ /** `kind_conflict` REPORTS A CHANGE RATHER THAN PERFORMING ONE (FR-002a). Zod
+ * cannot reach this decision: it depends on the stored row's kind and, for a
+ * promotion, on whether that row has ever sent a message. */
+ status: "created" | "updated" | "revived" | "kind_conflict";
+ }> {
const id = randomUUID();
const inserted = await this.db
.insert(users)
.values({
id,
environmentId: this.environmentId,
externalId,
displayName: profile.display_name ?? null,
avatarUrl: profile.avatar_url ?? null,
...(profile.metadata === undefined ? {} : { metadata: profile.metadata }),
+ // THE DEFAULT APPLIES HERE AND NOWHERE ELSE (FR-002b, T019a).
+ // A new row with no `kind` is a person; an existing row with no `kind` is
+ // asking for no change, which the update block below is careful about.
+ ...(profile.kind === undefined ? {} : { kind: profile.kind }),
+ ...(profile.description === undefined
+ ? {}
+ : { description: profile.description }),
})
.onConflictDoNothing({ target: [users.environmentId, users.externalId] })
.returning({ id: users.id });
if (inserted.length > 0) {
return {
@@ -1166,12 +1229,14 @@ export class Repository {
external_id: externalId,
display_name: profile.display_name ?? null,
avatar_url: profile.avatar_url ?? null,
metadata: profile.metadata ?? {},
banned_at: null,
deleted_at: null,
+ kind: profile.kind ?? "person",
+ description: profile.description ?? null,
},
status: "created",
};
}
// ONE UNREACHABLE THROW, NOT TWO, and the count is the reason. An earlier version
@@ -1185,41 +1250,96 @@ export class Repository {
//
// The pre-image is read for ONE fact the update cannot return — whether the row was
// deleted before this call, which is what makes the difference between `updated` and
// `revived`. `UPDATE ... RETURNING` gives post-update values, so there is no way to
// learn it from the write itself.
const [before] = await this.db
- .select({ id: users.id, deletedAt: users.deletedAt })
+ .select({ id: users.id, deletedAt: users.deletedAt, kind: users.kind })
.from(users)
.where(
and(
eq(users.environmentId, this.environmentId),
eq(users.externalId, externalId),
),
)
.limit(1);
- if (before !== undefined) {
+ // A KIND CHANGE IS REPORTED, NOT PERFORMED (FR-002a, FR-002d).
+ //
+ // `person -> bot` is allowed when the row has NEVER SENT A MESSAGE. Without that
+ // escape the natural ordering traps a customer: `POST /v1/channels/:id/members`
+ // creates any unknown identifier as a person, because `createUser` cannot set
+ // `kind` — so "add support-bot to #support" followed by "register support-bot as a
+ // bot" would make that bot permanently impossible. The escape closes at the first
+ // message, because a message already attributed to a person must not turn into one
+ // attributed to software.
+ //
+ // `bot -> person` is refused unconditionally. A bot's messages are attributed to it
+ // and demoting it would rewrite what those messages mean, retroactively.
+ //
+ // THE COST IS A FILTERED SCAN. `messages.user_id` carries no index and this asks
+ // whether one row exists, so `LIMIT 1` is doing the work: the planner stops at the
+ // first hit rather than counting. Measured in `baseline.txt` (T018b) rather than
+ // assumed, and no index was added for a question asked once per promotion.
+ // A THIRD THROW OF THE SAME CLASS IS WHAT THE RATCHET CAUGHT, AND DELETING IT IS THE
+ // FIX. The first version of this branch read the row back and threw if
+ // it was absent, then returned `kind_conflict` — which is the second statement for one
+ // impossible state that the comment forty lines below already argues against. Lines
+ // fell to **98.95%** against a pin of 99 and the gate went red, exactly as that
+ // comment predicts. Third time this project has answered the ratchet by removing code
+ // rather than covering it (the user-surface chapter's `addMember` and its `upsertUser`).
+ //
+ // The flag defers to the read the method already does at the end, so the conflict
+ // costs no extra query and no extra throw.
+ let kindConflict = false;
+ if (before !== undefined && profile.kind !== undefined && profile.kind !== before.kind) {
+ const promotable =
+ before.kind === "person" &&
+ profile.kind === "bot" &&
+ (
+ await this.db
+ .select({ id: messages.id })
+ .from(messages)
+ .where(eq(messages.userId, before.id))
+ .limit(1)
+ ).length === 0;
+ kindConflict = !promotable;
+ }
+
+ if (before !== undefined && !kindConflict) {
await this.db
.update(users)
.set({
// Absent stays absent, exactly as the single PATCH treats it — except
// `deleted_at`, which a revival always clears.
...(profile.display_name === undefined
? {}
: { displayName: profile.display_name }),
...(profile.avatar_url === undefined ? {} : { avatarUrl: profile.avatar_url }),
...(profile.metadata === undefined ? {} : { metadata: profile.metadata }),
+ // ABSENT STAYS ABSENT FOR `kind` TOO (T019a). An entry that omits it is not
+ // asking for `'person'`; the column default is for new rows only.
+ ...(profile.kind === undefined ? {} : { kind: profile.kind }),
+ ...(profile.description === undefined
+ ? {}
+ : { description: profile.description }),
deletedAt: null,
})
.where(and(eq(users.id, before.id), eq(users.environmentId, this.environmentId)));
}
const after = await this.getUserByExternalId(externalId);
if (after === null) throw new Error(`user ${externalId} could not be created or read`);
- return { user: after, status: before?.deletedAt != null ? "revived" : "updated" };
+ return {
+ user: after,
+ status: kindConflict
+ ? "kind_conflict"
+ : before?.deletedAt != null
+ ? "revived"
+ : "updated",
+ };
}
/** Ban and unban a user, tenant-wide (FR-031, FR-032).
*
* TENANT-SCOPED AND NOT A REMOVAL. A ban stops the user connecting and sending
* anywhere in the environment; it takes no membership away and hides no history. So
@@ -1289,12 +1409,30 @@ export class Repository {
)
.limit(1);
if (alive === undefined) return false;
await tx.delete(readPositions).where(eq(readPositions.userId, userId));
await tx.delete(members).where(eq(members.userId, userId));
+ // `description` IS NOT IN THIS `set`, AND ITS ABSENCE IS THE REQUIREMENT
+ // (FR-004a, T043b).
+ //
+ // FR-027 clears profile data on deletion, and a bot's description is not profile
+ // data — it says what the software is, which is what makes the messages it already
+ // sent answerable after it is gone. Clearing it would violate
+ // `users_bot_description_check` and make a bot **the one kind of user that cannot
+ // be deleted**: the constraint would reject the deletion itself.
+ //
+ // The rejected alternative was clearing `kind` back to `'person'` first. That
+ // makes the deletion two writes and leaves a person nobody created, holding
+ // messages a bot sent.
+ //
+ // THE OTHER DELETION METHOD IS `markUserDeleted`, and it clears nothing — it only
+ // stamps the marker. It has **no production caller**: the user-surface chapter added it so the
+ // listing's 404 branch was reachable before the deletion route existed. This rule
+ // is `deleteUser`'s, and a reader looking for it in the other one will find a
+ // method nothing calls.
await tx
.update(users)
.set({
displayName: null,
avatarUrl: null,
metadata: {},
@@ -1328,18 +1466,24 @@ export class Repository {
async updateUserProfile(
userId: string,
patch: {
display_name?: string | null | undefined;
avatar_url?: string | null | undefined;
metadata?: Record<string, unknown> | undefined;
+ /** FR-004. `string | undefined` and NOT `| null`, unlike its three
+ * neighbours: the boundary refuses a null (FR-004b) because
+ * `users_bot_description_check` would raise on a bot, so a null can never arrive
+ * here and widening the type would invite one. */
+ description?: string | undefined;
},
): Promise<UserRow | null> {
const set: Record<string, unknown> = {};
if (patch.display_name !== undefined) set["displayName"] = patch.display_name;
if (patch.avatar_url !== undefined) set["avatarUrl"] = patch.avatar_url;
if (patch.metadata !== undefined) set["metadata"] = patch.metadata;
+ if (patch.description !== undefined) set["description"] = patch.description;
if (Object.keys(set).length > 0) {
const updated = await this.db
.update(users)
.set(set)
.where(
@@ -1631,19 +1775,48 @@ export class Repository {
{
userId,
userExternalId,
text,
metadata,
idempotencyKey,
+ senderMustBeBot = false,
}: {
- userId?: string;
+ /** THE SENDER MUST BE A BOT (FR-007, T030, T032).
+ *
+ * A CONSTRAINT, NOT A CREDENTIAL CLASS. Research R5 says the repository must not
+ * learn what a credential is, and it does not: it is told that this send's sender
+ * has to be software, and the controller is the only thing that knows an
+ * application key is why.
+ *
+ * IT LIVES HERE BECAUSE OF THE ORDER, and the order was the finding. The
+ * documented sequence (`contracts/sending.md`) puts "may this credential send as
+ * that sender?" last, after the channel checks, so that a refusal naming a fact
+ * about a user cannot be provoked for a channel the caller could not otherwise
+ * reach. Enforcing it in the service would put it FIRST — before the ban, the
+ * visibility and the archive — and leak exactly what the ordering protects.
+ * There is no way to be both last and outside this transaction. */
+ senderMustBeBot?: boolean;
+ /** REQUIRED SINCE THIS CHAPTER (FR-MSG-15, FR-006), and required is the whole
+ * mechanism. SC-003 asks that no write path be able to produce a senderless
+ * message; a runtime check would be a test somebody has to remember, and this
+ * is a compile error. `exactOptionalPropertyTypes` means a caller cannot pass
+ * `undefined` here either — passing a `string | undefined` is named by the
+ * compiler, not silently accepted.
+ *
+ * There is no red test for this. Reverting the `?` is what makes the guarantee
+ * visible, and the transcript of that revert is SC-003a's evidence (T013a). */
+ userId: string;
/** The sender as a CONSUMER will see them. Threaded from the
* caller rather than looked up here — the internal route already holds it
* (it is the token's subject), and an extra SELECT inside the write
- * transaction is a cost every message would pay forever. Absent on the
- * public REST route, where a key-authenticated send is unattributed. */
+ * transaction is a cost every message would pay forever.
+ *
+ * STILL OPTIONAL, and that is not an oversight. `userId` is what the platform
+ * stores and `userExternalId` is what a consumer sees; the public route now
+ * resolves a bot and holds both, but the internal route has always supplied
+ * both and nothing requires a caller to know the external id to write a row. */
userExternalId?: string;
text: string;
metadata?: unknown;
idempotencyKey?: string;
},
): Promise<MessageRow> {
@@ -1655,25 +1828,39 @@ export class Repository {
// wrote `banned_at`. The position is the requirement: **before the channel is
// resolved**, so a banned user gets one answer for every channel id — real,
// foreign or invented. Put it after the channel read and the refusal for a
// channel that exists differs from the refusal for one that does not, and a
// banned user can enumerate channel ids.
//
- // ONLY FOR AN ATTRIBUTED SEND. A key-authenticated REST send carries no user, so
- // there is nobody to be banned; the tenant acting for itself is not a banned
- // user's send by proxy, because the tenant is who bans.
- if (userId !== undefined) {
- const [sender] = await tx
- .select({ bannedAt: users.bannedAt })
- .from(users)
- .where(
- and(eq(users.id, userId), eq(users.environmentId, this.environmentId)),
- )
- .limit(1);
- if (sender?.bannedAt != null) throw new UserBannedError(userId);
- }
+ // EVERY SEND IS ATTRIBUTED NOW (FR-MSG-15). The gate that used to
+ // stand here — `if (userId !== undefined)` — guarded against a key-authenticated
+ // send that carried no user, and `userId` is required as of this chapter, so the
+ // condition could no longer be false. **Fourth time this project has met a guard
+ // that stopped meaning anything**: `addMember`'s `rowCount ?? 0`, and
+ // `upsertUser`'s second throw and `(row.metadata ?? {})`. Tightening a
+ // type makes its runtime guards dead; three of the seven `userId` comparisons in
+ // this file were dead the moment T012 landed, and two others are in methods where
+ // the parameter is optional by design and must not be touched.
+ //
+ // A BOT CAN BE BANNED, AND THAT IS THE POINT (FR-005c). `banned_at` has been on
+ // every `users` row since the channel-control chapter and this check has never run for a bot
+ // because no send named one. A ban is how an operator stops a runaway integration
+ // without deleting the identity its messages are attributed to.
+ //
+ // ONE LOOKUP, TWO ANSWERS. `kind` is read here and used again at the private
+ // channel check below (FR-019a). The alternative is a second SELECT on the write
+ // path for every message forever, to learn something this query already touched.
+ const [sender] = await tx
+ .select({ bannedAt: users.bannedAt, kind: users.kind })
+ .from(users)
+ .where(
+ and(eq(users.id, userId), eq(users.environmentId, this.environmentId)),
+ )
+ .limit(1);
+ if (sender?.bannedAt != null) throw new UserBannedError(userId);
+ const senderIsPerson = sender?.kind !== "bot";
const [channel] = await tx
.select({
id: channels.id,
lastSequence: channels.lastSequence,
// `channels.type` has been a `"public" | "private"` column
@@ -1726,21 +1913,61 @@ export class Repository {
// FR-021a's ORDER is ban, then membership and visibility, then archive. The
// ban goes ahead of the channel read entirely, so a banned user gets one
// answer for every channel id; the archive check goes below this one, so a
// non-member of a private archived channel never learns it exists from
// `channel_archived`. Both arrive with their own columns' chapters; this is
// the middle of the three.
- if (channel.type === "private" && userId !== undefined) {
+ // THE SENDER ATTRIBUTES; IT DOES NOT AUTHORISE (FR-019).
+ //
+ // This gate used to read `channel.type === "private" && userId !== undefined`,
+ // and the second half was doing real work: a key-authenticated send carried no
+ // user, so it skipped the membership check entirely. That is the channel-control chapter's
+ // FR-005 — an application credential "acts for the customer, carries no user,
+ // and sees private channels" — and `messages.itest.ts` asserts it by name.
+ //
+ // Requiring `userId` would have made the condition always true, fired the check,
+ // and refused a bot that is not a member with `ChannelNotFoundError`: a 404 that
+ // by design cannot say why. A capability the channel-control chapter delivered would have
+ // vanished, and the analysis passes that read FR-005 never noticed because the
+ // word "private" appeared nowhere in this chapter's plan.
+ //
+ // So the gate turns on WHAT THE SENDER IS, not on whether there is one. A key
+ // naming a bot has exactly the authority the key has today; the bot's name is
+ // what appears on the message and nothing more. A person's token still both
+ // authorises and attributes, which is why `senderIsPerson` is the condition and
+ // a person who is not a member is still refused, indistinguishably (FR-019b).
+ if (channel.type === "private" && senderIsPerson) {
const [membership] = await tx
.select({ userId: members.userId })
.from(members)
.where(and(eq(members.channelId, channelId), eq(members.userId, userId)))
.limit(1);
if (!membership) throw new ChannelNotFoundError(channelId);
}
+ // THE SENDER'S KIND, FOURTH OF THE FIVE (FR-007, T032).
+ //
+ // After the ban and the visibility, because this refusal names a fact about a
+ // USER — "that identifier is a person" — and a caller who could not otherwise
+ // reach this channel must not be able to ask it. Same reasoning as
+ // archive-after-visibility below, one subject over.
+ //
+ // BEFORE THE ARCHIVE CHECK, AND THAT PAIR IS THE ONE ORDERING HERE THAT DOES NOT
+ // MATTER. Both of these refusals are addressed to a caller who has already been
+ // shown the channel exists, and each names something that caller already knows —
+ // the identifier it chose, or a state it can read. Swapping them changes which
+ // code an integrator sees first and leaks nothing either way. Said explicitly
+ // because every other adjacency in this sequence is load-bearing, and a reader
+ // who finds one that is not should be told rather than left to test it.
+ //
+ // `senderIsPerson` was computed at the ban check from the same row, so this costs
+ // nothing beyond the comparison.
+ if (senderMustBeBot && senderIsPerson) {
+ throw new SenderNotPermittedError(userId);
+ }
+
// ARCHIVE, AFTER VISIBILITY AND NOT BEFORE (FR-020, FR-021,
// FR-021a).
//
// The order is the requirement, not an implementation detail. Put this check
// above the membership one and a non-member of a private ARCHIVED channel
// learns it exists from `channel_archived` — a refusal that reveals what it isuserId?: string became userId: string. With exactOptionalPropertyTypes a caller cannot
pass undefined either — passing a string | undefined is named by the compiler, not silently
accepted. A senderless write does not build.
A naive grep for sendMessage( returns 71 occurrences across sixteen files. Making userId
required and running the compiler names 27, in eight files, every one of them inside
@relay/api — the gateway and the sealed end-to-end package reach this through HTTP and cannot
see the type at all.
Twenty-six of the twenty-seven are the same error, TS2345: a test calling sendMessage with
{ text } and no sender. The twenty-seventh is TS2379, a different code, and it is
messages.service.ts — the only production caller in the list. It does not omit userId; it
passes string | undefined, which exactOptionalPropertyTypes refuses just as firmly.
That asymmetry is the useful part. A census that counts sites omitting a property cannot see
the site that passes undefined, and the site that passes undefined was the only one where
the fix was a decision rather than a fixture. The compiler answered a question the grep could
not be asked.
flowchart TB
g["grep -c 'sendMessage('<br/>71 occurrences, 16 files"]
g --> t["make userId required,<br/>run the compiler"]
t --> c["27 errors, 8 files,<br/>all inside @relay/api"]
c --> a["26 x TS2345<br/>a test passing { text }<br/>and no sender"]
c --> b["1 x TS2379 — messages.service.ts<br/>the ONLY production caller.<br/>it does not OMIT userId,<br/>it passes string | undefined"]
b --> l["a count of what omits a property<br/>cannot see the site that passes<br/>a possibly-undefined value —<br/>and that site was the only DECISION"]
style b fill:#7f1d1d,color:#fff,stroke:#dc2626
style l fill:#1e3a5f,color:#fff,stroke:#3b82f6Requiring a parameter makes its runtime guards dead. sendMessage had a gate around the ban
check that existed only because a key send carried no user: no user, nobody to be banned. It is
unreachable the moment the type changes. Fourth time this project has met a guard that stopped
meaning something — two in upsertUser in the user-surface chapter, and addMember's
rowCount ?? 0, which had been carrying a comment saying it was gone since that chapter and is
only now actually removed.
The plan said to grep for the pattern rather than fix the one known instance. Good instinct. The
grep over repository.ts at the previous chapter's tag returns four, and they do not all
mean the same thing:
1664 the ban check DEAD — remove the gate, keep the check
1732 private-channel membership NOT DEAD IN EFFECT
1988 channelVisibleTo(id, userId?) LEGITIMATELY OPTIONAL — do not touch
2049 listMessages LEGITIMATELY OPTIONAL — do not touchOne of the four is dead, one looks dead and is not, and two are functions whose caller genuinely may not have a user. A rule that treated all four alike would have been right about one of them.
What the near-miss produced is the distinction the rest of the chapter rests on:
The sender attributes; it does not authorise. A person's token does both at once, which is why nothing had ever needed to name them apart. A key naming a bot has exactly the authority the key has today; the bot's name is what appears on the message and nothing more. So the membership gate turns on what the sender is, not on whether there is one — and a person who is not a member is still refused, indistinguishably.
@@ -7,13 +7,13 @@ import {
Post,
Query,
Req,
UseGuards,
} from "@nestjs/common";
-import { CredentialGuard } from "../auth/credential.guard";
+import { Accepts, CredentialGuard } from "../auth/credential.guard";
import { Repository } from "../db/repository";
import { MessagesService } from "./messages.service";
import { historyQuerySchema, sendMessageBodySchema } from "./messages.schema";
// `import type` is required, not stylistic: with isolatedModules and
// emitDecoratorMetadata on (ADR-15's trade-off, chapter 1.4), a type used
// in a decorated signature must be imported as a type or TS1272 refuses
@@ -22,30 +22,44 @@ import type { HistoryQuery, SendMessageBody } from "./messages.schema";
import type { RequestWithPrincipal } from "../auth/principal";
import { ZodValidationPipe } from "./zod-validation.pipe";
/** The end user this request acts for, or `undefined` when the tenant is acting.
*
* SOFT, unlike `internal.controller.ts`'s `principalUser`, which throws. These two
- * routes accept BOTH credential classes — the class-level guard declares no
- * `@Accepts`, so `credential.guard.ts` falls back to `EITHER` — and an application
- * key legitimately carries no user. A tenant's own server sending on a customer's
- * behalf is FR-MSG-13, not a mistake. */
+ * routes accept both credential classes — declared as `@Accepts("application", "user")`
+ * since this chapter, rather than inherited from `credential.guard.ts`'s `EITHER`
+ * fallback — and an application key carries no user OF ITS OWN.
+ *
+ * THIS COMMENT SAID SOMETHING ELSE UNTIL THIS CHAPTER, and what it said was the reading
+ * that made the gap invisible: *"A tenant's own server sending on a customer's behalf is
+ * FR-MSG-13, not a mistake."* FR-MSG-13 said the system shall support sending **on behalf
+ * of a user**, and this route named nobody — so from the chapter that added this route
+ * until this one, the clause was cited by the code that did the opposite of it. The clause is now narrowed to a bot user of
+ * that tenant, and the sender comes from the body (`user`), resolved below. */
function actingUser(req: RequestWithPrincipal): string | undefined {
return req.principal?.kind === "user" ? req.principal.userExternalId : undefined;
}
// The api's first product endpoint (chapter 2.2). Validation is zod at the
// boundary — the same schema family as @relay/protocol, so the REST body
// and the WebSocket frame payload cannot drift (1.3's payoff, again).
//
// The credentials chapter swapped the guard. `EnvironmentContextGuard` resolved a tenant
// from a header the caller asserted; `CredentialGuard` only asks whether the
// principal the middleware already resolved is allowed here. Both classes are
-// (FR-MSG-13 lets a server send on a user's behalf, and FR-AUT-10 does not
-// reserve these routes), so this one declares nothing narrower.
+// accepted (FR-MSG-13 lets a server send on behalf of a bot user of its tenant, and
+// FR-AUT-10 does not reserve these routes) — and the sender chapter made that a DECLARATION
+// rather than a fallback, because a fallback is what let the gateway's credential reach
+// `POST /internal/dispatch/replay` in the isolation harness.
+// DECLARED, NOT INHERITED FROM A FALLBACK (T027a). Until now this class
+// declared no `@Accepts` and `credential.guard.ts` fell back to `EITHER` — the fallback
+// its own comment names as the thing that let the gateway's credential reach
+// `POST /internal/dispatch/replay` in the isolation harness. Both classes are genuinely accepted
+// here, so the declaration says the same thing the fallback did and says it on purpose.
@Controller("v1/channels/:channelId/messages")
+@Accepts("application", "user")
@UseGuards(CredentialGuard)
export class MessagesController {
constructor(
private readonly messages: MessagesService,
private readonly repo: Repository,
) {}
@@ -76,24 +90,70 @@ export class MessagesController {
// identifiers that need not exist, so before this a token-authenticated send
// by a stranger succeeded UNATTRIBUTED — and an unattributed send is one the
// membership check waves through. A user with no row is a member of nothing;
// refusing is the honest answer, and it is the same one the internal route has
// given since chapter 2.6. FR-039a removes the case entirely by creating the
// row when the token is minted.
- const actingExternalId = actingUser(req);
- let userId: string | undefined;
- if (actingExternalId !== undefined) {
- const user = await this.repo.getUserByExternalId(actingExternalId);
- if (!user) throw new BadRequestException("unknown user");
- userId = user.id;
+ // THE SENDER, RESOLVED PER CREDENTIAL CLASS (FR-010, FR-008).
+ //
+ // A user token attributes to its subject and MAY NOT name anybody else: a token is
+ // both an authorisation and an attribution, so a body `user` beside one is either a
+ // mistake or an attempt to post as someone else, and both deserve the same refusal.
+ //
+ // An application credential carries no user of its own, so the body's `user` is the
+ // only thing that can name one — and naming nothing is refused, because FR-MSG-15
+ // says every message has a sender.
+ //
+ // THE SENDER ATTRIBUTES; IT DOES NOT AUTHORISE (FR-019). What a key may reach is
+ // decided by the key. Naming a bot does not widen that, and the repository's
+ // private-channel check turns on the sender's `kind` for exactly this reason.
+ const tokenSubject = actingUser(req);
+ if (tokenSubject !== undefined && body.user !== undefined) {
+ throw new BadRequestException({
+ code: "invalid_request",
+ message:
+ "a user token is already attributed to its subject; remove `user` from the body",
+ field: "user",
+ });
+ }
+ const actingExternalId = tokenSubject ?? body.user;
+ if (actingExternalId === undefined) {
+ throw new BadRequestException({
+ code: "invalid_request",
+ message: "name the sender in `user` — an application credential has no user of its own",
+ field: "user",
+ });
+ }
+ // ONE THROW FOR BOTH FAILURES, WHICH IS THE INDISTINGUISHABILITY (T031, SC-005).
+ //
+ // An identifier belonging to another tenant and an identifier belonging to nobody
+ // are the same answer here, because there is only one place that answers. Resolving
+ // the id per tenant is what `getUserByExternalId` already does — a foreign bot is
+ // simply absent from this environment — so the two cannot diverge by construction
+ // rather than by two branches that happen to agree today.
+ //
+ // The message names no identifier. A bot's external id is often its purpose spelled
+ // out, so echoing it back would say "this exists somewhere" about the one string the
+ // caller most wants confirmed.
+ const user = await this.repo.getUserByExternalId(actingExternalId);
+ if (!user) {
+ throw new BadRequestException({
+ code: "invalid_request",
+ message: "the sender named in `user` is not a user of this environment",
+ field: "user",
+ });
}
const message = await this.messages.send(
channelId,
body,
- userId,
+ user.id,
actingExternalId,
+ // The class the credential presented, so the service can apply the bot rule
+ // without learning what a credential is (R5). A boolean rather than the
+ // principal: the service needs one fact, not the request.
+ tokenSubject === undefined,
);
// FR-MSG-04's "201-equivalent semantics" lives HERE, on the public
// wire: the client sees the same body whether this was the original
// send or the retry that recovered it. Moved down from the service in
// chapter 2.6, where an internal caller turned out to need the flag.
// The field list is spelled out rather than spread-minus-`duplicate`,
@@ -102,12 +162,17 @@ export class MessagesController {
return {
id: message.id,
channel_id: message.channel_id,
seq: message.seq,
text: message.text,
created_at: message.created_at,
+ // THE SENDER IT USED (FR-009a). A caller now required to name one
+ // gets told which was recorded — and for a user token, which it inferred. The
+ // internal send has carried this since chapter 2.6; the public one answered five
+ // fields and left the caller to assume.
+ user: actingExternalId,
};
}
@Get()
async history(
@Param("channelId") channelId: string,A user token attributes to its subject and may not name anybody else — a token is both an
authorisation and an attribution, so a body user beside one is either a mistake or an attempt
to post as someone else. An application credential carries no user of its own, so the body's
user is the only thing that can name one, and naming nothing is refused with the field named.
Then the refusal that needed its own code:
@@ -37,12 +37,30 @@ export const ERROR_CODES = {
// failure, so it gets its own code instead of a generic `unauthorized`: the
// response has to say which class was presented and which the route wanted.
// The MESSAGE names the class and never the credential — "the key rk_dev_abc…
// is invalid" is how a live secret reaches a support ticket (NFR-SEC-06).
wrong_credential_type:
"the credential class presented cannot use this route; the message names presented and expected",
+ // THE THIRD IN THE SAME FAMILY, one dimension further over. Its two
+ // siblings are directly above: `wrong_credential_type` is the wrong CLASS,
+ // `wrong_credential_service` the wrong SERVICE, and this one is the right class
+ // holding the right service naming the wrong KIND OF USER — an application key
+ // asking to post as a person.
+ //
+ // NOT `forbidden`, for the reason this file has now given twice. `ProtocolErrorFilter`
+ // maps a bare 403 to `forbidden`, so this is the one code in the chapter that collides
+ // with the filter's ladder, and the ladder is what has to lose: "you lack a
+ // permission" is a different fact from "a key may speak as software and not as any
+ // person", and only the second tells an integrator what to change.
+ //
+ // The MESSAGE names neither the person asked for nor the bots available. Which
+ // identifiers exist in a tenant is exactly what the isolation oracle exists to keep
+ // out of a refusal (FR-009, SC-005), and a message listing the acceptable senders
+ // would be an enumeration endpoint with a 403 in front of it.
+ sender_not_permitted:
+ "an application credential may send only as a bot user; name one in `user`",
// ── THIS CHAPTER'S THREE (FR-021, FR-031, research R11) ──────────────────────
//
// Three refusals a client acts on differently, which is the test this registry
// sets: `channel_member_limit_exceeded` above is separate from a quota because one
// resets on a date and the other never does. Reusing `forbidden` for all threeThe contract for this route documents five refusals in order: ban, visibility, archive, then does-the-sender-resolve, then may-this-credential-send-as-it. That order is not achievable as written, and finding out why changed where the last check lives.
flowchart TB
r["resolve the named sender<br/>400, field: user"]
r --> b["is the sender BANNED?<br/>403 user_banned"]
b --> v["can the sender SEE the channel?<br/>404, as if absent"]
v --> k["may this credential send AS IT?<br/>403 sender_not_permitted"]
k --> a["is the channel ARCHIVED?<br/>403 channel_archived"]
r --- why1["the contract numbered this FOURTH.<br/>the ban check reads the sender's ROW,<br/>so resolution cannot come after it"]
v --- why2["AFTER VISIBILITY, both of them.<br/>each names a fact the caller must not be<br/>able to provoke for a channel it cannot reach"]
k --- why3["and the k/a pair is the one adjacency<br/>here that does NOT matter: both address<br/>a caller already shown the channel exists"]
style r fill:#1e3a5f,color:#fff,stroke:#3b82f6
style v fill:#1e3a5f,color:#fff,stroke:#3b82f6
style why3 fill:#3f3f46,color:#fff,stroke:#71717aThe ban check reads the sender's row — a ban applies to the sender named, not to the caller, which is what makes banning a bot meaningful. So the sender must already be resolved before the first check can run. Resolution is step zero, not step four.
What survives is the part the contract actually argues for: resolution before the kind check, so that a refusal naming a fact about a user cannot be provoked for a channel the caller could not otherwise reach. The two are no longer adjacent — resolution is step zero and the kind check is fourth of the five, one place above the archive:
@@ -9,12 +9,13 @@ import {
ChannelArchivedError,
UserBannedError,
ChannelNotFoundError,
Repository,
type MessageRow,
type MessageWithSender,
+ SenderNotPermittedError,
} from "../db/repository";
import { protocolError } from "../protocol-error";
import { decodeCursor, encodeCursor } from "./cursor";
import type { HistoryQuery, SendMessageBody } from "./messages.schema";
// The thin layer between HTTP and the repository (chapters 2.2 + 2.3). It
@@ -31,13 +32,16 @@ export class MessagesService {
constructor(private readonly repo: Repository) {}
async send(
channelId: string,
body: SendMessageBody,
/** Chapter 2.6: who wrote it. Optional because an APPLICATION-key send is
- * unattributed — it acts for the tenant and there is no user to name.
+ * unattributed — it acted for the tenant and there was no user to name. **That is no
+ * longer true**: the sender chapter made every message carry a sender (FR-MSG-15), and a key
+ * names a bot user of its tenant. The parameter below is required at the repository and
+ * resolved by the controller before this method is called.
*
* IT IS NO LONGER OPTIONAL FOR A USER TOKEN. THE CHANNEL-CONTROL CHAPTER made the public
* route resolve its principal (T031a): the membership check in `sendMessage`
* is gated on this parameter, and until then the public route supplied none,
* so the check could not fire on the route a customer's client actually calls.
* "A key-authenticated public send is unattributed" was the old bound and it
@@ -45,18 +49,33 @@ export class MessagesService {
userId?: string,
/** The same person as a CONSUMER will see them. The event
* envelope carries external ids, and the internal route already holds this
* one — it is the token's subject — so threading it costs nothing where a
* lookup inside the write transaction would cost a query per message. */
userExternalId?: string,
+ /** Whether the caller is an application credential (FR-007, T030).
+ *
+ * A BOOLEAN, NOT THE PRINCIPAL. The service needs one fact to apply the bot rule and
+ * has no business holding the request; the controller is what knows about credential
+ * classes. Passed through to the repository as `senderMustBeBot`, which knows even
+ * less — only that this send's sender has to be software (R5). */
+ senderMustBeBot = false,
): Promise<MessageRow> {
try {
+ // THE SENDER IS RESOLVED BEFORE HERE (FR-008). The controller does
+ // it per credential class and refuses an absent or unresolvable one with a 400
+ // naming `user`, so by this line there is a sender and it exists in this tenant.
+ // What remains is the narrowing the compiler needs.
+ if (userId === undefined) {
+ throw new Error("a message must name its sender (FR-MSG-15, FR-008)");
+ }
return await this.repo.sendMessage(channelId, {
text: body.text,
metadata: body.metadata,
- ...(userId !== undefined && { userId }),
+ userId,
+ senderMustBeBot,
...(userExternalId !== undefined && { userExternalId }),
...(body.idempotency_key != null && {
idempotencyKey: body.idempotency_key,
}),
});
} catch (error) {
@@ -67,12 +86,31 @@ export class MessagesService {
// tenant, and one that was invented. The gauntlet asserts exactly that pair.
//
// NOT the not-found envelope, unlike the private-channel refusal. A ban is a fact
// about the CALLER, not about the channel, so saying so reveals nothing about what
// channels exist — and a client that cannot tell "you are banned" from "no such
// channel" retries for ever against a wall.
+ // 403 `sender_not_permitted`, AND NOT `forbidden` (FR-007a, T032a).
+ //
+ // `ProtocolErrorFilter` maps a bare 403 to `forbidden`, and this is the only code
+ // in the chapter that collides with the ladder — so it is named here, the way
+ // the isolation harness named `wrong_credential_service` for the same reason. The filter
+ // prefers an explicit code when one is given; leaving it to the ladder would put
+ // "you lack a permission" on the wire in place of the one fact an integrator can
+ // act on.
+ //
+ // THE MESSAGE NAMES NOBODY. Not the person asked for, not the bots that would
+ // have worked. Which identifiers exist in a tenant is what the oracle exists to
+ // keep out of a refusal (SC-005).
+ if (error instanceof SenderNotPermittedError) {
+ throw protocolError(
+ "sender_not_permitted",
+ "an application credential may send only as a bot user; name one in `user`",
+ HttpStatus.FORBIDDEN,
+ );
+ }
if (error instanceof UserBannedError) {
throw protocolError(
"user_banned",
"this user is banned in this environment and cannot send messages",
HttpStatus.FORBIDDEN,
);Which decided where the kind check lives. The plan said the service and not the repository,
because the repository must not learn what a credential is. But the service runs before the
repository, so enforcing it there would put it ahead of the ban, the visibility and the archive
— and leak exactly what the ordering protects. It is in the repository, told senderMustBeBot:
a constraint, not a credential class. The repository still does not know what a credential is.
A bot is a user, so it inherits everything keyed on one: membership with a role, a channel listing, an unread count, a ban, a deletion that keeps its messages attributed. What it cannot do is authenticate:
@@ -89,13 +89,37 @@ export class DevTokenController {
// and reading the answer. The token is the answer either way.
//
// IT ALSO CANNOT LIFT A BAN OR A DELETION. `createUser` touches no column on
// an existing row — its own comment is about refusing to rename anybody — so
// `banned_at` and `deleted_at` survive a mint. `upsertUser` is the route that
// clears state, and it clears only `deleted_at`, because FR-030 asks it to.
- await new Repository(this.db, principal.environmentId).createUser(body.user);
+ // A BOT CANNOT OBTAIN A TOKEN (FR-005, T040, T041).
+ //
+ // `createUser` still creates a PERSON for an unknown identifier — FR-005a, and the
+ // paragraph above is why that matters — so this refusal is only ever about a row
+ // that already exists and is already software. A bot is an identity messages are
+ // sent AS, not an account that logs in, and a token is the one thing that would make
+ // it the second.
+ //
+ // 404 `not_found`, AND THERE IS NO INDISTINGUISHABLE ANSWER AVAILABLE. Everywhere
+ // else in this chapter a refusal is made byte-identical to the refusal for an
+ // identifier that exists nowhere — but on this route an unknown identifier answers
+ // **200 with a token**, because the user-surface chapter made the mint create the row. So there
+ // is nothing for a refusal to be identical to: any refusal at all says "this
+ // identifier exists and is not a person". That is a leak this route cannot close,
+ // and 404 is chosen because it is the answer this route already gives for an
+ // environment it cannot resolve — one shape rather than a new one (FR-005).
+ const repo = new Repository(this.db, principal.environmentId);
+ const existing = await repo.getUserByExternalId(body.user);
+ if (existing?.kind === "bot") {
+ throw new NotFoundException({
+ code: "not_found",
+ message: "no such user",
+ });
+ }
+ await repo.createUser(body.user);
const { token, expiresAt } = await mintUserToken(environment.signingSecret, {
user: body.user,
environmentId: principal.environmentId,
ttlSeconds: body.ttl_seconds ?? DEFAULT_TTL_SECONDS,
});@@ -52,12 +52,27 @@ export class SessionController {
// than a client error.
if (principal?.kind !== "user") {
throw new UnauthorizedException("a verified end-user token is required");
}
const user = await this.repo.getUserByExternalId(principal.userExternalId);
+ // A BOT MAY NOT OPEN A SOCKET EITHER (FR-005b, T040a).
+ //
+ // REFUSING AT THE MINT IS NOT ENOUGH, and the window is the reason. A token lives
+ // up to 24 hours (FR-AUT-07), so a user promoted to a bot at 09:00 holds a valid
+ // token until 09:00 tomorrow — and this route reads `banned_at` and, until now, not
+ // `kind`. Closing the mint alone would leave a bot able to connect for a day after
+ // it became one, which is the same shape as a ban that only takes effect on the
+ // next connect.
+ //
+ // The socket sees a closed connection rather than a 404: the gateway calls this
+ // route and has nothing to say to a client whose session was refused, which is why
+ // the test for this lives in the gateway's suite and not here.
+ if (user?.kind === "bot") {
+ throw new UnauthorizedException("a bot user cannot open a session");
+ }
// A verified token for a user this environment has never seen is not an
// error: it is a user with no channels. The gateway's job is delivery, not
// identity forensics — 2.5's rule, and the reason a first connect from a
// brand-new user works before anything is seeded.
return {
environment_id: principal.environmentId,This is also the one refusal in the chapter that cannot be made indistinguishable. Everywhere else, a refusal for a foreign identifier is byte-identical to one for an identifier that exists nowhere. On the mint, an unknown identifier answers 200 with a token — the user-surface chapter made it create the row — so there is nothing for a refusal to be identical to. Any refusal at all says "this identifier exists and is not a person". That leak cannot be closed on this route, and saying so is better than an assertion pretending otherwise.
19,818 messages in this lane have no sender, across 1,047 environments. The chapter's rule is about new writes; those rows stay, and FR-012 asks that all four read paths keep working for them. The requirement asks for one more thing: that the answer be the same on all four.
It cannot be, and the reason is the contracts rather than the code:
history listMessages user: null readable
listing last_message user: null readable
webhook MessageCreatedData user: null readable
resume toFrame row DROPPED not renderablemessageSchema.user is z.string().min(1) — a frame cannot carry a null at all, and changing
that would break every published client. So what is the same on all four is the decision:
never invent a sender. Where a contract can express "nobody" it says null; where it cannot,
the row is not delivered. That is a weaker sentence than the requirement asks for and it is the
true one.
MessageCreatedData.user stays string | null for the same reason. Nothing can create a new
null — the compiler forbids it — but a webhook retry runs for up to two hours, and the queue was
already full when the rule changed. Narrowing the type would be asserting that something in
flight cannot happen.
That is the only claim in this chapter with a test that has to fail if somebody tightens the
type later, so it has one. FR-WHK-02 delivers message.created to a customer's own endpoint and
FR-WHK-03 retries for two hours: a legacy row can leave the platform, and leave it again, after
this chapter ships.
@@ -88,6 +88,35 @@ describe("messageCreatedEvent", () => {
environmentId: "",
message: MESSAGE,
}),
).toThrow(/environment/i);
});
});
+
+describe("a legacy senderless message in the webhook payload (T054a)", () => {
+ // THE ONE PATH THAT LEAVES THE PLATFORM. FR-WHK-02 delivers `message.created` to a
+ // customer's own HTTPS endpoint and FR-WHK-03 retries a failed delivery for up to two
+ // hours — so an event for a legacy senderless row can be delivered, and REdelivered,
+ // after this chapter ships. A subscriber's parser meets it whatever the api now
+ // refuses to create.
+ //
+ // `MessageCreatedData.user` STAYS `string | null` (T054b, FR-012a). Nothing can create
+ // a new null: FR-MSG-15 requires a sender and the compiler enforces it. The type is
+ // not describing what the platform emits — it describes what a subscriber may still
+ // receive from a queue that was already full when the rule changed. Narrowing it to
+ // `string` would be a type that says "this cannot happen" about something in flight.
+ it("carries user: null rather than dropping the event", () => {
+ const event = messageCreatedEvent({
+ eventId: "9a0b1c2d-3e4f-4a5b-8c9d-0e1f2a3b4c5d",
+ environmentId: ENV,
+ message: { ...MESSAGE, user: null },
+ });
+ // `messageCreatedEvent` returns a `PendingEvent` — a subject and the envelope —
+ // so the payload a subscriber parses is two levels in.
+ const payload = event.payload.data;
+ // NOT DROPPED, unlike the resume. The webhook's contract permits a null where
+ // `messageSchema.user` (`z.string().min(1)`) does not, so the two paths differ in
+ // what they can express and agree on the decision: never invent a sender.
+ expect(payload.user).toBeNull();
+ expect(JSON.stringify(payload)).not.toContain("user_id");
+ });
+});This is a breaking change to a route shipped in chapter 2.2, and calling it anything else would
be dishonest. An application key that posts to POST /v1/channels/:channelId/messages and does
not name a user receives 400 where it used to receive 201. The fix is two requests: create
a bot once, then name it on every send.
A user token's send is unaffected — it names nobody and is attributed to its subject, exactly as before. The break falls entirely on key-authenticated senders, which is the class the requirement was always about.
Thirty-one files in the platform repository changed to make this true, and every one of them is fenced — in this chapter, because no other chapter claims them. Twenty of the thirty-one are below, in one block, with nothing said about them.
Every send site in the workspace had to name a sender, and most of them are tests. A fixture gaining a bot is not a lesson, so none of this is worth prose — but the fence chain does not care why a file changed. A claimed path's state must equal the repository's, so they are here in one place rather than scattered through sections with nothing to say about them.
The user-surface chapter met the same shape from the other side, and in this order it does not have it any more. In the sequence this book was first written, eleven of its files changed by exactly one word — a citation being corrected — which gave it eleven fences with no subject. The naming convention deleted the commit that produced them, and the fences went with it. What is below is the inverse of what those were: subjects a reader does not need, on paths the chain still has to account for.
@@ -100,12 +100,21 @@ describe("the three refusals this chapter's channel adds", () => {
});
it("says an archive leaves history readable", () => {
expect(ERROR_CODES.channel_archived).toMatch(/history is still readable/);
});
+ it("registers the refusal a bot-only credential gives a person", () => {
+ // THIS CHAPTER'S ONE CODE. `sender_not_permitted` is the fifth check on the send
+ // path and the only one whose subject is a fact about the SENDER rather than the
+ // channel — so it needs a code of its own, for the reason the credentials chapter
+ // gave when it added `wrong_credential_type` instead of a generic 403.
+ expect(ERROR_CODES).toHaveProperty("sender_not_permitted");
+ expect(ERROR_CODES.sender_not_permitted).not.toBe("");
+ });
+
it("never lets not_a_member announce that the channel exists", () => {
// THE LEAK FR-003 FORBIDS, in the one place it can be written by accident. A
// private channel the caller cannot see must answer the not-found envelope, so a
// description saying "the channel exists and…" would put the oracle in the text
// even when the status code is right.
expect(ERROR_CODES.not_a_member).not.toMatch(/\bexists?\b/);@@ -1,12 +1,13 @@
import "reflect-metadata";
import { Test } from "@nestjs/testing";
import type { INestApplication } from "@nestjs/common";
import { SignJWT } from "jose";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { randomUUID } from "node:crypto";
import { AppModule } from "../app.module";
import { createDb, createPool, type Db } from "../db/client";
import {
createApiKey,
createEnvironment,
@@ -90,13 +91,25 @@ describe("credentials", () => {
env = await createEnvironment(db, { name: "credentials-itest" });
key = await createApiKey(db, { environmentId: env.id });
const repo = new Repository(db, env.id);
channelId = (await repo.createChannel("general", "public")).id;
await repo.createUser("tuan", "Tuan");
+ // A BOT IN EACH ENVIRONMENT. A key send names one, and the FOREIGN
+ // key must name a bot of ITS OWN tenant — otherwise the attack below would be
+ // refused for naming an unresolvable sender (400) rather than for reaching a channel
+ // it cannot see (404), and the test would stop attacking what it attacked.
+ await repo.upsertUser("cred-bot", {
+ kind: "bot",
+ description: "sends so a credential test has something to send",
+ });
foreign = await createEnvironment(db, { name: "credentials-itest-other" });
+ await new Repository(db, foreign.id).upsertUser("cred-bot", {
+ kind: "bot",
+ description: "the other tenant's own software",
+ });
foreignKey = await createApiKey(db, { environmentId: foreign.id });
foreignChannelId = (
await new Repository(db, foreign.id).createChannel("theirs", "public")
).id;
production = await createEnvironment(db, {
@@ -157,13 +170,13 @@ describe("credentials", () => {
).rows,
);
expect(stored).not.toContain(secret);
expect(stored).not.toContain(minted.credential);
// And it still works — unrecoverable is not the same as unusable.
- expect((await post({ text: "with the new key" }, minted.credential)).status).toBe(
+ expect((await post({ text: "with the new key", user: "cred-bot" }, minted.credential)).status).toBe(
201,
);
});
it("invariant 2: no credential is a 401 that names what the route expects", async () => {
const res = await post({ text: "anonymous" });
@@ -187,47 +200,52 @@ describe("credentials", () => {
expect(body.message).toMatch(/end-user token/i);
// Never the credential itself (NFR-SEC-06).
expect(body.message).not.toContain(token);
});
it("invariant 4: a foreign key sees nothing, and it looks exactly like absent", async () => {
- const foreignAnswer = await post({ text: "trespass" }, foreignKey.credential);
+ // The foreign key names a bot of ITS OWN tenant, so the only thing wrong with this
+ // request is the channel — which is what the test is about.
+ const foreignAnswer = await post(
+ { text: "trespass", user: "cred-bot" },
+ foreignKey.credential,
+ );
const absentAnswer = await post(
- { text: "nowhere" },
+ { text: "nowhere", user: "cred-bot" },
key.credential,
"00000000-0000-0000-0000-000000000000",
);
expect(foreignAnswer.status).toBe(404);
expect(absentAnswer.status).toBe(404);
expect(await foreignAnswer.json()).toEqual(await absentAnswer.json());
// And the reverse direction, so the test cannot pass by both being broken.
expect(
- (await post({ text: "mine" }, foreignKey.credential, foreignChannelId))
+ (await post({ text: "mine", user: "cred-bot" }, foreignKey.credential, foreignChannelId))
.status,
).toBe(201);
});
it("invariant 5: a revoked key is refused on the very next request", async () => {
const doomed = await createApiKey(db, {
environmentId: env.id,
name: "doomed",
});
- expect((await post({ text: "before" }, doomed.credential)).status).toBe(201);
+ expect((await post({ text: "before", user: "cred-bot" }, doomed.credential)).status).toBe(201);
await revokeApiKey(db, doomed.id);
// No wait, no cache to expire: verification is a live query (research R7).
- expect((await post({ text: "after" }, doomed.credential)).status).toBe(401);
+ expect((await post({ text: "after", user: "cred-bot" }, doomed.credential)).status).toBe(401);
});
it("invariant 6: several active keys work at once, which is what rotation needs", async () => {
const second = await createApiKey(db, {
environmentId: env.id,
name: "rotation",
});
- expect((await post({ text: "old key" }, key.credential)).status).toBe(201);
- expect((await post({ text: "new key" }, second.credential)).status).toBe(201);
+ expect((await post({ text: "old key", user: "cred-bot" }, key.credential)).status).toBe(201);
+ expect((await post({ text: "new key", user: "cred-bot" }, second.credential)).status).toBe(201);
});
it("invariant 7: a token is refused when expired, malformed, mis-signed, foreign, or over-long", async () => {
const now = Math.floor(Date.now() / 1000);
const read = (credential?: string) =>
fetch(`${url}/v1/channels/${channelId}/messages`, {
@@ -253,12 +271,48 @@ describe("credentials", () => {
}),
)
).status,
).toBe(401);
});
+ // ── T042: the mint's three cases (FR-005, FR-005a, SC-006) ──
+ //
+ // DO NOT ASSERT BYTE-IDENTITY WITH THE UNKNOWN CASE. Everywhere else in this chapter a
+ // refusal is made indistinguishable from the refusal for an identifier that exists
+ // nowhere — here the unknown case SUCCEEDS, because the user-surface chapter made the mint create
+ // the row. There is nothing to be identical to, and any refusal at all says "this
+ // identifier exists and is not a person". That is a leak this route cannot close, and
+ // saying so is better than an assertion that pretends otherwise.
+ it("mints for an unknown identifier and creates it as a PERSON", async () => {
+ const fresh = `never-seen-${randomUUID().slice(0, 8)}`;
+ const res = await devToken(key.credential, { user: fresh });
+ expect(res.status).toBe(200);
+ // FR-005a: implicit creation must not produce a bot, or a customer could make one
+ // by accident and then find it cannot authenticate.
+ const created = await new Repository(db, env.id).getUserByExternalId(fresh);
+ expect(created?.kind).toBe("person");
+ });
+
+ it("mints for a person who already exists", async () => {
+ const repo = new Repository(db, env.id);
+ const who = `person-${randomUUID().slice(0, 8)}`;
+ await repo.createUser(who, "A Person");
+ expect((await devToken(key.credential, { user: who })).status).toBe(200);
+ });
+
+ it("refuses a bot with 404 — a bot is not an account", async () => {
+ const repo = new Repository(db, env.id);
+ const who = `bot-${randomUUID().slice(0, 8)}`;
+ await repo.upsertUser(who, { kind: "bot", description: "cannot log in" });
+ const res = await devToken(key.credential, { user: who });
+ expect(res.status).toBe(404);
+ expect((await res.json()).code).toBe("not_found");
+ // FR-005a's other half: the refusal must not have converted anything.
+ expect((await repo.getUserByExternalId(who))?.kind).toBe("bot");
+ });
+
it("invariant 9: the dev-token endpoint mints in development and does not exist in production", async () => {
const minted = await devToken(key.credential);
expect(minted.status).toBe(200);
const body = (await minted.json()) as { token: string; expires_at: string };
expect(typeof body.token).toBe("string");
expect(Date.parse(body.expires_at)).toBeGreaterThan(Date.now());
@@ -346,14 +400,20 @@ describe("credentials", () => {
expect(again.created).toBe(false);
expect(again.apiKey).toBeUndefined();
// And the key it did hand over works on the environment it belongs to.
const repo = new Repository(db, first.environment.id);
const channel = await repo.createChannel("signup-key", "public");
+ // A THIRD ENVIRONMENT, seeded by signup rather than by this file's `beforeAll` — so
+ // it needs its own bot.
+ await repo.upsertUser("cred-bot", {
+ kind: "bot",
+ description: "the freshly signed-up tenant's own software",
+ });
expect(
- (await post({ text: "bootstrapped" }, first.apiKey!.secret, channel.id))
+ (await post({ text: "bootstrapped", user: "cred-bot" }, first.apiKey!.secret, channel.id))
.status,
).toBe(201);
});
// ══ FR-USR-02: A USER ROW ON FIRST AUTHENTICATION ════════════
//@@ -1,11 +1,12 @@
import "reflect-metadata";
import { Test } from "@nestjs/testing";
import type { INestApplication } from "@nestjs/common";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { randomUUID } from "node:crypto";
import { AppModule } from "../app.module";
import { mintUserToken } from "../auth/user-token";
import { createDb, createPool, type Db } from "../db/client";
import { createApiKey, createEnvironment, Repository } from "../db/repository";
import { environmentSigningSecret } from "../db/repository";
@@ -510,12 +511,14 @@ describe("the public channel surface", () => {
expect(read.status).toBe(200);
expect(await read.json()).toMatchObject({ is_member: false });
const sent = await fetch(`${url}/v1/channels/${publicChannelId}/messages`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
+ // NO `user` — this is a USER TOKEN, and naming one beside a token is refused
+ // (FR-010). Adding it here was a reflex during T061 and the test caught it.
body: JSON.stringify({ text: "still open to me" }),
});
expect(sent.status).toBe(201);
});
});
@@ -640,18 +643,31 @@ describe("the public channel surface", () => {
headers: { authorization: `Bearer ${credential}` },
});
const sendTo = (channel: string) =>
fetch(`${url}/v1/channels/${channel}/messages`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${credential}` },
- body: JSON.stringify({ text: "attempted after archiving" }),
+ body: JSON.stringify({
+ text: "attempted after archiving",
+ user: "archive-bot",
+ }),
});
beforeAll(async () => {
archived = (await repo.createChannel("archivable", "public")).id;
- await repo.sendMessage(archived, { text: "written before archiving" });
+ // A key send names a bot. The refusal under test is the ARCHIVE's,
+ // so the sender must resolve or the test would be measuring a 400 about `user`.
+ await repo.upsertUser("archive-bot", {
+ kind: "bot",
+ description: "sends at an archived channel so the refusal can be checked",
+ });
+ const scribe = (await repo.createUser(`ch-${randomUUID().slice(0, 8)}`)).id;
+ await repo.sendMessage(archived, {
+ text: "written before archiving",
+ userId: scribe,
+ });
});
it("refuses a send with its own code, distinct from not-found and banned", async () => {
// T074, and FR-021's actual requirement: three refusals a client acts on
// differently. The comparison used to be against `not_a_member`, which cannot
// appear on this path at all — private channels answer not-found and public
@@ -704,13 +720,19 @@ describe("the public channel surface", () => {
// position is what it was, and the arithmetic between them is unchanged.
//
// Asserted on the sequence rather than on a count, because the count is phase
// 12's route — this is the invariant that makes the count safe, tested where it
// can be tested.
const target = (await repo.createChannel("archive-unread", "public")).id;
- const before = (await repo.sendMessage(target, { text: "unread by somebody" })).seq;
+ const teller = (await repo.createUser(`ch-${randomUUID().slice(0, 8)}`)).id;
+ const before = (
+ await repo.sendMessage(target, {
+ text: "unread by somebody",
+ userId: teller,
+ })
+ ).seq;
await archive(target);
const after = await repo.listMessages(target, { limit: 10 });
expect(after.map((m) => m.seq)).toContain(before);
// And the channel's sequence did not move: archiving is not a write to the log.
const reread = await repo.getChannelById(target);
expect(reread).not.toBeNull();@@ -1,7 +1,8 @@
import { beforeAll, describe, expect, it } from "vitest";
+import { randomUUID } from "node:crypto";
import { desc, eq, sql } from "drizzle-orm";
import { createDb, createPool, DEFAULT_DATABASE_URL, type Db } from "./client";
import { migrate } from "./migrate";
import { createEnvironment, Repository } from "./repository";
import { messages } from "./schema";
@@ -49,33 +50,35 @@ beforeAll(async () => {
const env = await createEnvironment(db, { name: "history-drift-itest" });
repo = new Repository(db, env.id);
});
describe("offset pagination drifts under live inserts (chapter 2.4)", () => {
it("serves rows the reader has already seen", async () => {
+ const sender = (await repo.createUser(`drift-${randomUUID().slice(0, 8)}`)).id;
const channel = await repo.createChannel("drift-repeat", "public");
for (let i = 1; i <= 60; i += 1) {
- await repo.sendMessage(channel.id, { text: `m-${i}` });
+ await repo.sendMessage(channel.id, { text: `m-${i}`, userId: sender });
}
const page1 = await readByOffset(channel.id, { offset: 0, limit: 50 });
// The feed moves mid-scroll: three drivers type while page two loads.
for (let i = 1; i <= 3; i += 1) {
- await repo.sendMessage(channel.id, { text: `live-${i}` });
+ await repo.sendMessage(channel.id, { text: `live-${i}`, userId: sender });
}
const page2 = await readByOffset(channel.id, { offset: 50, limit: 50 });
const seen = new Set(page1.map((m) => m.id));
const repeats = page2.filter((m) => seen.has(m.id));
// Three inserts, three repeats — the drift is exactly the shift.
expect(repeats).toHaveLength(3);
});
it("hides rows the reader will never see, when the feed shrinks", async () => {
+ const sender = (await repo.createUser(`drift-${randomUUID().slice(0, 8)}`)).id;
const channel = await repo.createChannel("drift-gap", "public");
for (let i = 1; i <= 60; i += 1) {
- await repo.sendMessage(channel.id, { text: `m-${i}` });
+ await repo.sendMessage(channel.id, { text: `m-${i}`, userId: sender });
}
const page1 = await readByOffset(channel.id, { offset: 0, limit: 50 });
// A moderator deletes a message the reader has ALREADY passed — one
// from page one's range. Every row below it shifts up one position,
// so page two's offset now starts one row too late.
const victim = page1[25]!;@@ -92,37 +92,75 @@ describe("tenant isolation is structural (FR-TEN-05)", () => {
expect(again.id).toBe(inA!.id);
// And the existing display name wins: a second call is not an update.
expect(again.display_name).toBe(inA!.display_name);
});
});
+describe("the database refuses a bot with no description (FR-003)", () => {
+ // TWO GUARANTEES, NOT ONE, AND THIS IS THE SECOND (T023). Zod refuses a bot with no
+ // description at the boundary and that covers every request; this covers every
+ // WRITER — a migration, a backfill, a psql session, a future route nobody has
+ // written. Research R5 puts the two checks in two layers deliberately, and a test
+ // that only exercised the boundary would leave the constraint unproven.
+ it("refuses the insert directly, not only through the route", async () => {
+ const user = await repoA.createUser("db-refuses-me", "Person For Now");
+ // THE CONSTRAINT NAME IS ON THE CAUSE, NOT THE MESSAGE. Drizzle wraps the driver
+ // error as "Failed query: ...", so asserting on `toThrow(/users_bot.../)` passes
+ // for any failure of that statement — including a typo in the SQL. The name is
+ // what makes this test about the constraint rather than about the query.
+ await expect(
+ db.execute(sql`UPDATE users SET kind = 'bot' WHERE id = ${user.id}`),
+ ).rejects.toMatchObject({
+ cause: { constraint: "users_bot_description_check" },
+ });
+ });
+
+ it("accepts the same promotion when a description comes with it", async () => {
+ const user = await repoA.createUser("db-allows-me", "Person For Now");
+ await db.execute(
+ sql`UPDATE users SET kind = 'bot', description = 'it says what it does'
+ WHERE id = ${user.id}`,
+ );
+ expect((await repoA.getUserByExternalId("db-allows-me"))!.kind).toBe("bot");
+ });
+
+ it("refuses a kind outside the two the vocabulary allows", async () => {
+ const user = await repoA.createUser("db-refuses-kind", "Person");
+ await expect(
+ db.execute(sql`UPDATE users SET kind = 'daemon' WHERE id = ${user.id}`),
+ ).rejects.toMatchObject({ cause: { constraint: "users_kind_check" } });
+ });
+});
+
describe("sequence assignment is serialised per channel (ADR-03)", () => {
it("two concurrent sends never interleave", async () => {
const channel = await repoA.createChannel("ordering", "public");
+ const writer = (await repoA.createUser("ordering-writer", "Writer")).id;
const [a, b] = await Promise.all([
- repoA.sendMessage(channel.id, { text: "first writer" }),
- repoA.sendMessage(channel.id, { text: "second writer" }),
+ repoA.sendMessage(channel.id, { text: "first writer", userId: writer }),
+ repoA.sendMessage(channel.id, { text: "second writer", userId: writer }),
]);
// Two sends, two DISTINCT consecutive sequence numbers — always.
expect(new Set([a.seq, b.seq]).size).toBe(2);
expect(Math.abs(a.seq - b.seq)).toBe(1);
});
});
describe("idempotency must not disarm DR-01 (chapter 2.3)", () => {
it("a keyless send still fails loudly on a sequence collision", async () => {
const channel = await repoA.createChannel("dr01-guard", "public");
- await repoA.sendMessage(channel.id, { text: "first" });
+ const guard = (await repoA.createUser("dr01-guard-sender", "Sender")).id;
+ await repoA.sendMessage(channel.id, { text: "first", userId: guard });
// Rewind the counter so the next keyless send reuses seq 1. The
// conflict clause must NOT swallow this: DR-01's unique constraint is
// 2.2's safety net, and idempotency has no business disarming it.
await db.execute(
sql`UPDATE channels SET last_sequence = 0 WHERE id = ${channel.id}`,
);
await expect(
- repoA.sendMessage(channel.id, { text: "collides" }),
+ repoA.sendMessage(channel.id, { text: "collides", userId: guard }),
).rejects.toThrow();
// And nothing landed: the failed insert wrote no row.
const rows = await repoA.listMessagesRaw(channel.id);
expect(rows).toHaveLength(1);
});
});
@@ -198,13 +236,31 @@ describe("a private channel refuses a non-member's send (FR-001)", () => {
it("accepts an application credential with no user (FR-005)", async () => {
// `userId` absent means the TENANT is sending: it acts for the customer,
// carries no user, and is the customer's own server. FR-005 asked for this to
// be stated rather than assumed, and the assumption is that a private channel
// is not private FROM ITS OWNER.
const channel = await repoA.createChannel("private-app", "private");
- const sent = await repoA.sendMessage(channel.id, { text: "from the tenant" });
+ // A BOT, AND THAT IS THE WHOLE POINT NOW (FR-019a). This test is the
+ // repository-level twin of `messages.itest.ts`'s "accepts an application key's send
+ // to the same private channel". Before this chapter the send carried no user at all
+ // and skipped the membership check for that reason; now the check is gated on the
+ // sender being a PERSON, so a bot still gets through and a non-member person still
+ // does not. Give it a person here and the test inverts into its own opposite.
+ //
+ // `createUser` cannot set `kind` — that is `upsertUser`'s job from Phase 3 — so the
+ // promotion is a raw UPDATE, which is also the only writer that can satisfy
+ // `users_bot_description_check` in one statement.
+ const bot = (await repoA.createUser("private-app-bot", "Tenant Bot")).id;
+ await db.execute(
+ sql`UPDATE users SET kind = 'bot', description = 'posts on the tenant''s behalf'
+ WHERE id = ${bot}`,
+ );
+ const sent = await repoA.sendMessage(channel.id, {
+ text: "from the tenant",
+ userId: bot,
+ });
expect(sent.seq).toBe(1);
});
it("does not check membership on a public channel", async () => {
// FR-004's answer for the other type: any authenticated user of the tenant may
// send to a public channel without being a member. The column becomes live
@@ -488,13 +544,40 @@ describe("the repository's own refusals", () => {
// `userId`, by design since the outbox chapter. The listing's `last_message.user` is then
// null, and that arm has no route that can reach it: every send through the public
// channel route now carries a user, and the internal one resolves theirs.
const reader = await repoA.createUser("arm-no-author", "Reader");
const channel = await repoA.createChannel("arm-unattributed", "public");
await repoA.addMember(channel.id, reader.id);
- await repoA.sendMessage(channel.id, { text: "from the tenant, not a user" });
+ // PLANTED, BECAUSE NOTHING CAN WRITE ONE ANY MORE (T014a, FR-014).
+ //
+ // The subject of this test IS a senderless row, so the repository can no longer
+ // produce its own fixture: `sendMessage` requires a sender as of FR-MSG-15, which
+ // is exactly the guarantee this arm exists to describe the other side of. The row
+ // is inserted directly, the way the user-surface chapter's read-position clamp is
+ // planted a few
+ // hundred lines above — the only way a branch that no writer can reach is covered.
+ //
+ // THE ARM IS NOT DEAD, AND ITS SUBJECT HAS CHANGED (T055, FR-014).
+ //
+ // The user-surface chapter wrote this arm for a state the public route produced on every
+ // key-authenticated send. It now covers LEGACY ROWS ONLY: 121,250 of the 394,808
+ // messages in this lane have no sender (T050), and any deployment older than this
+ // chapter has them, but nothing can make another. R8 said re-examine rather than
+ // delete, and re-examining is what changes here — the assertion is the same and the
+ // reason for it is not.
+ //
+ // A test whose subject changed and whose comment did not is how a reader concludes
+ // the behaviour is still reachable from the outside.
+ await db.execute(
+ sql`INSERT INTO messages (id, channel_id, sequence, text, created_at)
+ VALUES (gen_random_uuid(), ${channel.id}, 1, 'from the tenant, not a user', now())`,
+ );
+ await db.execute(
+ sql`UPDATE channels SET last_sequence = 1, last_activity_at = now()
+ WHERE id = ${channel.id}`,
+ );
const { rows } = await repoA.listChannelsForUser(reader.id, { limit: 10 });
const row = rows.find((r) => r.external_id === "arm-unattributed")!;
expect(row.last_message?.text).toBe("from the tenant, not a user");
expect(row.last_message?.user).toBeNull();
});@@ -8,12 +8,14 @@ import {
BACKFILL_LIMIT,
internalBackfillResponseSchema,
MAX_RESUME_CHANNELS,
} from "@relay/protocol";
import { AppModule } from "../app.module";
+import { sql } from "drizzle-orm";
+
import { createDb, createPool } from "../db/client";
import {
createEnvironment,
environmentSigningSecret,
Repository,
} from "../db/repository";
@@ -166,15 +168,33 @@ describe("POST /internal/backfill", () => {
expect(body.channels[theirs]).toBeUndefined();
});
it("skips a message no frame can be built from, rather than inventing one", async () => {
const orphans = (await repo.createChannel("orphans", "public")).id;
await repo.addMember(orphans, tuan.id);
- // No userId: the shape of every row written through the socket before
- // 2.6's fix. There is no truthful sender to put on the wire.
- const anonymous = await repo.sendMessage(orphans, { text: "who said it?" });
+ // PLANTED, BECAUSE NOTHING CAN WRITE ONE ANY MORE (T014a, FR-014).
+ //
+ // This is the SECOND test whose subject is a senderless row, and T014a named only
+ // the first — `repository.itest.ts`'s `last_message.user` arm. Both had to stop
+ // using `sendMessage` for the same reason: FR-MSG-15 makes the sender required, so
+ // the repository can no longer produce the fixture that proves what happens without
+ // one. Found by the compiler rather than by reading, which is what Phase 2 is for.
+ //
+ // The shape is still real: every row written through the socket before 2.6's fix
+ // looks like this, and `toFrame` skipping them is the behaviour under test.
+ const anonymousSeq = 1;
+ const raw = createDb(createPool());
+ await raw.execute(
+ sql`INSERT INTO messages (id, channel_id, sequence, text, created_at)
+ VALUES (gen_random_uuid(), ${orphans}, ${anonymousSeq}, 'who said it?', now())`,
+ );
+ await raw.execute(
+ sql`UPDATE channels SET last_sequence = ${anonymousSeq}, last_activity_at = now()
+ WHERE id = ${orphans}`,
+ );
+ const anonymous = { seq: anonymousSeq };
const withAuthor = await say(orphans, "this one is attributable");
const page = (await parsed(await ask({ [orphans]: 0 }))).channels[orphans]!;
expect(page.messages.map((m) => m.seq)).toEqual([withAuthor.seq]);
// The gap is visible to the client as a missing sequence number — which
// is precisely the signal the SDK repairs through 2.4's history endpoint.
expect(page.messages.map((m) => m.seq)).not.toContain(anonymous.seq);@@ -18,12 +18,14 @@ import type { Db } from "../db/client";
*
* ONE OF EACH KIND OF ROW THE ROUTES TOUCH, because the write attacks read storage
* before and after: a channel and a message for the message routes, a user for the
* session route. */
export interface Tenant {
environmentId: string;
+ /** This tenant's own bot. A key send must name one. */
+ botExternalId: string;
/** An `rk_dev_…` credential for this environment, minted the way signup does. */
credential: string;
userId: string;
userExternalId: string;
channelId: string;
/** The customer-supplied identifier, so an attack can present the other tenant's own
@@ -45,22 +47,32 @@ async function seedTenant(db: Db, label: string): Promise<Tenant> {
const key = await createApiKey(db, { environmentId: environment.id });
const repo = new Repository(db, environment.id);
const userExternalId = `${label}-user`;
const user = await repo.createUser(userExternalId, `${label} user`);
const channelExternalId = `${label}-channel`;
+ // A BOT PER TENANT. Every attack in the gauntlet presents a KEY, and a
+ // key send names a bot — so each tenant needs one of its own, or an attack would be
+ // refused for naming an unresolvable sender rather than for the thing it attacks.
+ const bot = (
+ await repo.upsertUser(`${label}-bot`, {
+ kind: "bot",
+ description: `${label}'s own software`,
+ })
+ ).user;
const channel = await repo.createChannel(channelExternalId, "public", label);
await repo.addMember(channel.id, user.id);
const message = await repo.sendMessage(channel.id, {
text: `${label} says something`,
userId: user.id,
});
return {
environmentId: environment.id,
credential: key.credential,
+ botExternalId: bot.external_id,
userId: user.id,
userExternalId,
channelId: channel.id,
channelExternalId,
messageId: message.id,
repo,
@@ -104,12 +116,15 @@ export interface SameTenant {
/** A user of the same tenant who is NOT a member of it. The attacker. */
stranger: { id: string; externalId: string; token: string };
privateChannelId: string;
publicChannelId: string;
/** A message the member wrote, so a read attack has something to fail to find. */
messageId: string;
+ /** A bot of this tenant. The control's sender: an application
+ * credential may name this one and no other tenant's. */
+ bot: { id: string; externalId: string };
repo: Repository;
}
export async function seedSameTenant(db: Db, mintToken: MintToken): Promise<SameTenant> {
const stamp = Math.random().toString(36).slice(2, 8);
const environment = await createEnvironment(db, { name: `iso-same-${stamp}` });
@@ -123,12 +138,21 @@ export async function seedSameTenant(db: Db, mintToken: MintToken): Promise<Same
await repo.addMember(privateChannel.id, member.id);
const message = await repo.sendMessage(privateChannel.id, {
text: "written by a member",
userId: member.id,
userExternalId: member.external_id,
});
+ // A BOT, VIA THE UPSERT, because `createUser` cannot set `kind` — a bot needs a
+ // description and the member-add path has nowhere to put one.
+ const bot = (
+ await repo.upsertUser(`same-${stamp}-bot`, {
+ display_name: "A Bot",
+ kind: "bot",
+ description: "the tenant's own software, for the sender attacks",
+ })
+ ).user;
return {
environmentId: environment.id,
credential: key.credential,
member: {
id: member.id,
@@ -140,12 +164,13 @@ export async function seedSameTenant(db: Db, mintToken: MintToken): Promise<Same
externalId: stranger.external_id,
token: await mintToken(environment.id, stranger.external_id),
},
privateChannelId: privateChannel.id,
publicChannelId: publicChannel.id,
messageId: message.id,
+ bot: { id: bot.id, externalId: bot.external_id },
repo,
};
}
/** THE SAME `external_id` IN TWO ENVIRONMENTS, ONE PUBLIC AND ONE PRIVATE.
*@@ -3,13 +3,13 @@ import "reflect-metadata";
import type { INestApplication } from "@nestjs/common";
import { Test } from "@nestjs/testing";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { AppModule } from "../app.module";
import { mintUserToken } from "../auth/user-token";
-import { environmentSigningSecret } from "../db/repository";
+import { environmentSigningSecret, Repository } from "../db/repository";
import { createDb, createPool } from "../db/client";
import { credentialAttack, listAttack, readAttack, rowsOf, writeAttack } from "./attack";
import { withoutRequestId } from "./compare";
import {
nowhereId,
seedCollidingTenants,
@@ -106,28 +106,38 @@ describe("the isolation gauntlet", () => {
expect(verdict.differences, verdict.differences.join("; ")).toEqual([]);
});
// ── write ───────────────────────────────────────────────────────────────────────
it("POST /v1/channels/:channelId/messages — refuses, and writes nothing", async () => {
attacked.add("POST /v1/channels/:channelId/messages");
+ // THE ATTACK PRESENTS A KEY, AND A KEY SEND NAMES A BOT
+ // (FR-MSG-15). Without a sender both halves would be refused for naming
+ // nobody — identically, so the pair would agree and this test would pass
+ // while attacking the validator instead of the tenancy boundary.
+ const from = { text: "from the attacker", user: t.attacker.botExternalId };
const verdict = await writeAttack(
url,
t.attacker.credential,
{
method: "POST",
path: `/v1/channels/${t.victim.channelId}/messages`,
- body: { text: "from the attacker" },
+ body: from,
},
{
method: "POST",
path: `/v1/channels/${ABSENT_UUID}/messages`,
- body: { text: "from the attacker" },
+ body: from,
},
() => t.victim.repo.listMessages(t.victim.channelId, { limit: 50 }),
);
expect(verdict.differences, verdict.differences.join("; ")).toEqual([]);
+ // AND THE REFUSAL IS THE TENANCY ONE. Without this the pair above agrees on any
+ // shared refusal, including the validator's — drop `user` from `from` and both
+ // halves become 400 `field: "user"`, `differences` stays empty and this test goes
+ // on passing without ever reaching a channel.
+ expect(verdict.foreign.status).toBe(404);
// THE STATE READ IS THE POINT: a 404 that completed the write is the case no
// status code reveals.
expect(verdict.stateChanged, "the victim's messages moved").toBe(false);
});
it("POST /internal/messages — a foreign channel_id refuses, and writes nothing", async () => {
@@ -406,12 +416,77 @@ describe("the isolation gauntlet", () => {
headers: { authorization: `Bearer ${same.credential}` },
});
}
});
});
+ // ══ THE SENDER (T035, T036, SC-005) ════════════════════════
+ //
+ // HAND-WRITTEN, AND `attack.ts` NEEDS NO FIFTH SHAPE. The sender is a new DIMENSION
+ // on a route already classified `write` and already attacked with a foreign channel
+ // id — not a new kind of target. A generated shape would have to know that this
+ // body field names a user in the caller's own tenant, which is one route's
+ // knowledge and not the gauntlet's.
+ describe("a foreign bot and a bot that exists nowhere", () => {
+ // ── T036: THE CONTROL FIRST ───────────────────────────────────────────
+ //
+ // The isolation harness's fourteen green tests compared two refusals and meant nothing,
+ // because the thing they attacked was refused for an unrelated reason. If this
+ // control does not pass, the pair below proves only that both sends failed.
+ it("the control: the same credential, the same channel, its OWN bot — 201", async () => {
+ const res = await fetch(
+ `${url}/v1/channels/${same.publicChannelId}/messages`,
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ authorization: `Bearer ${same.credential}`,
+ },
+ body: JSON.stringify({ text: "the control", user: same.bot.externalId }),
+ },
+ );
+ expect(res.status).toBe(201);
+ });
+
+ it("refuses a foreign bot and an invented identifier identically", async () => {
+ const post = (user: string) =>
+ fetch(`${url}/v1/channels/${same.publicChannelId}/messages`, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ authorization: `Bearer ${same.credential}`,
+ },
+ body: JSON.stringify({ text: "not mine to send as", user }),
+ });
+
+ // A BOT IN ANOTHER TENANT, planted here rather than in the fixture: the point
+ // is a real, resolvable identifier that belongs to somebody else, and only this
+ // test needs one. `t.victim` is the tenant whose identifiers every attack
+ // in this file borrows.
+ const theirs = (
+ await new Repository(db, t.victim.environmentId).upsertUser(
+ "victim-bot",
+ { kind: "bot", description: "the victim tenant's own software" },
+ )
+ ).user;
+
+ const foreign = await post(theirs.external_id);
+ const invented = await post("a-bot-that-exists-in-no-tenant");
+
+ expect(foreign.status).toBe(400);
+ expect(invented.status).toBe(400);
+ const a = withoutRequestId(await foreign.json());
+ const b = withoutRequestId(await invented.json());
+ // If these differ by one byte, naming an identifier is a way to ask whether
+ // another tenant has one — and a bot's identifier is often its purpose spelled
+ // out, so the answer would leak what the neighbour's software does.
+ expect(a).toEqual(b);
+ expect((a as { field: string }).field).toBe("user");
+ });
+ });
+
it("a PUBLIC channel of the same tenant is open to the same non-member (FR-004)", async () => {
// The other half of what makes `channels.type` decide something. If both types
// refused, the column would still be deciding nothing.
const res = await asUser(same.stranger.token, "GET", `/v1/channels/${same.publicChannelId}`);
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ is_member: false });@@ -1,7 +1,9 @@
import { beforeAll, describe, expect, it } from "vitest";
+import { randomUUID } from "node:crypto";
+import { sql } from "drizzle-orm";
import {
createDb,
createPool,
DEFAULT_DATABASE_URL,
type Db,
@@ -22,21 +24,46 @@ if (!["localhost", "127.0.0.1"].includes(url.hostname)) {
}
const pool = createPool();
const db: Db = createDb(pool);
let repo: Repository;
+// A REAL SENDER FOR THE WHOLE SUITE (FR-MSG-15). History is about
+// paging and ordering, not about who wrote what, so one row serves every page.
+let sender: string;
+
beforeAll(async () => {
await migrate(pool);
const env = await createEnvironment(db, { name: "history-itest" });
repo = new Repository(db, env.id);
+ sender = (await repo.createUser("history-sender", "History Sender")).id;
});
+// ── T052: what history shows for a legacy senderless row (SC-008, FR-013) ──
+//
+// PLANTED, BECAUSE NOTHING CAN WRITE ONE ANY MORE. `sendMessage` requires a sender as of
+// FR-MSG-15, so the fixture for "a row with no sender" has to be inserted directly — the
+// same technique as `repository.itest.ts`'s listing arm and `backfill.itest.ts`'s drop.
+//
+// 121,250 of the 394,808 messages in this lane have no sender (T050). Any deployment that
+// has been running since before this chapter has them, which is why FR-012 asks that all
+// four read paths keep working rather than treating them as a curiosity.
+async function plantSenderless(channelId: string, seq: number, text: string) {
+ await db.execute(
+ sql`INSERT INTO messages (id, channel_id, sequence, text, created_at)
+ VALUES (gen_random_uuid(), ${channelId}, ${seq}, ${text}, now())`,
+ );
+ await db.execute(
+ sql`UPDATE channels SET last_sequence = greatest(last_sequence, ${seq})
+ WHERE id = ${channelId}`,
+ );
+}
+
async function seed(channelId: string, count: number, prefix: string) {
for (let i = 1; i <= count; i += 1) {
- await repo.sendMessage(channelId, { text: `${prefix}-${i}` });
+ await repo.sendMessage(channelId, { text: `${prefix}-${i}`, userId: sender });
}
}
describe("history pagination (FR-MSG-09)", () => {
it("cursor pagination is stable under the same live inserts", async () => {
const channel = await repo.createChannel("hist-cursor", "public");
@@ -79,13 +106,17 @@ describe("history pagination (FR-MSG-09)", () => {
it("a foreign channel's history is empty, not forbidden", async () => {
const other = await createEnvironment(db, { name: "history-itest-other" });
const theirs = await new Repository(db, other.id).createChannel(
"hist-theirs",
"public",
);
+ const theirSender = (
+ await new Repository(db, other.id).createUser(`h-${randomUUID().slice(0, 8)}`)
+ ).id;
await new Repository(db, other.id).sendMessage(theirs.id, {
+ userId: theirSender,
text: "theirs",
});
expect(await repo.listMessages(theirs.id, { limit: 50 })).toEqual([]);
});
it("cursors survive the round trip the endpoint makes", async () => {
@@ -96,7 +127,24 @@ describe("history pagination (FR-MSG-09)", () => {
const next = await repo.listMessages(channel.id, {
beforeSeq: decodeCursor(token)!,
limit: 2,
});
expect(next[0]!.seq).toBe(1);
});
+
+ it("shows a legacy senderless row with user: null, and keeps it in the page", async () => {
+ const channel = await repo.createChannel(`legacy-${Date.now()}`, "public");
+ await plantSenderless(channel.id, 1, "written before there were senders");
+ await repo.sendMessage(channel.id, {
+ text: "written after",
+ userId: sender,
+ });
+
+ const page = await repo.listMessages(channel.id, { limit: 10 });
+ // BOTH ROWS ARE THERE. History's contract permits a null sender, so the legacy row is
+ // readable — it is not hidden, and its sequence number is not a gap.
+ expect(page).toHaveLength(2);
+ const legacy = page.find((m) => m.seq === 1);
+ expect(legacy?.text).toBe("written before there were senders");
+ expect(legacy?.user).toBeNull();
+ });
});@@ -28,43 +28,51 @@ if (!["localhost", "127.0.0.1"].includes(url.hostname)) {
}
const pool = createPool();
const db: Db = createDb(pool);
let env: Environment;
let repo: Repository;
+// A REAL SENDER, BECAUSE `sendMessage` REQUIRES ONE (FR-MSG-15).
+//
+// One row for the whole suite, created here rather than a `userId: "x"` at each call
+// site. A fixture that invents an id to satisfy a compiler is a test that stopped
+// meaning what it meant: this suite is about idempotency, and every message in it is
+// now sent by somebody who exists.
+let sender: string;
beforeAll(async () => {
await migrate(pool);
env = await createEnvironment(db, { name: "idempotency-itest" });
repo = new Repository(db, env.id);
+ sender = (await repo.createUser("idempotency-sender", "Idempotency Sender")).id;
});
afterAll(async () => {
await pool.end();
});
describe("idempotency enforcement (FR-MSG-04, DR-03)", () => {
it("a retry WITHOUT a key duplicates the message — journey 4's failure, staged", async () => {
const channel = await repo.createChannel("idem-no-key", "public");
- await repo.sendMessage(channel.id, { text: "B2, north ramp" });
+ await repo.sendMessage(channel.id, { userId: sender, text: "B2, north ramp" });
// The ack was lost; the client cannot know. It retries:
- await repo.sendMessage(channel.id, { text: "B2, north ramp" });
+ await repo.sendMessage(channel.id, { userId: sender, text: "B2, north ramp" });
const rows = await repo.listMessagesRaw(channel.id);
// Two rows, seq 1 and 2, identical text. The dispatcher reads it twice.
expect(rows.filter((m) => m.text === "B2, north ramp")).toHaveLength(2);
});
it("a retry WITH a key returns the ORIGINAL message — the fix", async () => {
const channel = await repo.createChannel("idem-with-key", "public");
const key = randomUUID();
- const first = await repo.sendMessage(channel.id, {
+ const first = await repo.sendMessage(channel.id, { userId: sender,
text: "B2, north ramp",
idempotencyKey: key,
});
// The ack was lost; the client retries with the same key:
- const retry = await repo.sendMessage(channel.id, {
+ const retry = await repo.sendMessage(channel.id, { userId: sender,
text: "B2, north ramp",
idempotencyKey: key,
});
// One row, ever.
const rows = await repo.listMessagesRaw(channel.id);
expect(rows.filter((m) => m.text === "B2, north ramp")).toHaveLength(1);
@@ -76,13 +84,13 @@ describe("idempotency enforcement (FR-MSG-04, DR-03)", () => {
it("five concurrent sends with the SAME key produce exactly one row", async () => {
const channel = await repo.createChannel("idem-concurrent", "public");
const key = randomUUID();
const results = await Promise.all(
Array.from({ length: 5 }, () =>
- repo.sendMessage(channel.id, {
+ repo.sendMessage(channel.id, { userId: sender,
text: "concurrent send",
idempotencyKey: key,
}),
),
);
// Exactly one row in the database.
@@ -95,31 +103,31 @@ describe("idempotency enforcement (FR-MSG-04, DR-03)", () => {
expect(seqs.size).toBe(1);
});
it("a recognised duplicate consumes no sequence number", async () => {
const channel = await repo.createChannel("idem-no-burn", "public");
const key = randomUUID();
- await repo.sendMessage(channel.id, { text: "once", idempotencyKey: key });
- await repo.sendMessage(channel.id, { text: "once", idempotencyKey: key });
- await repo.sendMessage(channel.id, { text: "once", idempotencyKey: key });
+ await repo.sendMessage(channel.id, { userId: sender, text: "once", idempotencyKey: key });
+ await repo.sendMessage(channel.id, { userId: sender, text: "once", idempotencyKey: key });
+ await repo.sendMessage(channel.id, { userId: sender, text: "once", idempotencyKey: key });
// Three sends, one row — and the NEXT message gets seq 2, not seq 4:
// a retry that wrote nothing spends nothing (FR-MSG-02 tolerates gaps,
// but there is no reason to manufacture them).
- const next = await repo.sendMessage(channel.id, { text: "after" });
+ const next = await repo.sendMessage(channel.id, { userId: sender, text: "after" });
expect(next.seq).toBe(2);
});
it("the same key in DIFFERENT channels produces two rows — key namespace is the channel", async () => {
const channelA = await repo.createChannel("idem-ns-a", "public");
const channelB = await repo.createChannel("idem-ns-b", "public");
const key = randomUUID();
- const a = await repo.sendMessage(channelA.id, {
+ const a = await repo.sendMessage(channelA.id, { userId: sender,
text: "same key, different channel",
idempotencyKey: key,
});
- const b = await repo.sendMessage(channelB.id, {
+ const b = await repo.sendMessage(channelB.id, { userId: sender,
text: "same key, different channel",
idempotencyKey: key,
});
// Two distinct rows — the key's scope is (channel_id, key), not global.
expect(a.id).not.toBe(b.id);
expect(@@ -1,11 +1,12 @@
import "reflect-metadata";
import { Test } from "@nestjs/testing";
import type { INestApplication } from "@nestjs/common";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { randomUUID } from "node:crypto";
import { AppModule } from "../app.module";
import { mintUserToken } from "../auth/user-token";
import { environmentSigningSecret } from "../db/repository";
import { createDb, createPool } from "../db/client";
import { createApiKey, createEnvironment, Repository } from "../db/repository";
@@ -53,12 +54,21 @@ describe("POST /v1/channels/:channelId/messages", () => {
// until the read paths enforce it (FR-009's ordering).
repo = new Repository(db, env.id);
privateChannelId = (await repo.createChannel("members-only", "private")).id;
const member = await repo.createUser("insider", "An Insider");
await repo.addMember(privateChannelId, member.id);
await repo.createUser("outsider", "An Outsider");
+ // A BOT OF THIS TENANT, and a person, for the sender chapter's four outcomes. Added
+ // beside the existing fixtures and NOT added to `privateChannelId` — that
+ // membership is load-bearing for the tests above, and a bot needs none of it
+ // (FR-019a) which is the point T012c makes.
+ await repo.upsertUser("courier", {
+ display_name: "Courier",
+ kind: "bot",
+ description: "delivers build results into the channel",
+ });
const signingSecret = (await environmentSigningSecret(db, env.id))!
.signingSecret;
tokenFor = async (subject: string) =>
(
await mintUserToken(signingSecret, {
user: subject,
@@ -74,32 +84,35 @@ describe("POST /v1/channels/:channelId/messages", () => {
});
afterAll(async () => {
await app.close();
});
+ // KEY SENDS NAME `courier`, THE TENANT'S BOT (T059). Each caller passes
+ // its own body, so the sender is added per call rather than defaulted here — a default
+ // would hide which tests are about the sender and which merely need one.
const send = (body: unknown, channel = channelId, key = credential) =>
fetch(`${url}/v1/channels/${channel}/messages`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${key}`,
},
body: JSON.stringify(body),
});
it("returns 201 with an ascending sequence", async () => {
- const first = await send({ text: "hello" });
+ const first = await send({ text: "hello", user: "courier" });
expect(first.status).toBe(201);
const a = (await first.json()) as { seq: number };
- const b = (await (await send({ text: "again" })).json()) as { seq: number };
+ const b = (await (await send({ text: "again", user: "courier" })).json()) as { seq: number };
expect(b.seq).toBe(a.seq + 1);
});
it("rejects a malformed body through the protocol envelope", async () => {
- const res = await send({ text: "" });
+ const res = await send({ text: "", user: "courier" });
expect(res.status).toBe(400);
const body = (await res.json()) as Record<string, unknown>;
expect(body).toMatchObject({ code: "invalid_request" });
expect(typeof body.docs_url).toBe("string");
});
@@ -120,14 +133,14 @@ describe("POST /v1/channels/:channelId/messages", () => {
expect(foreign.status).toBe(404);
expect(missing.status).toBe(404);
expect(await foreign.json()).toEqual(await missing.json());
});
it("answers a FOREIGN channel id with the same 404 as a missing one", async () => {
- const foreign = await send({ text: "not for you" }, foreignChannelId);
- const missing = await send({ text: "nobody home" }, crypto.randomUUID());
+ const foreign = await send({ text: "not for you", user: "courier" }, foreignChannelId);
+ const missing = await send({ text: "nobody home", user: "courier" }, crypto.randomUUID());
expect(foreign.status).toBe(404);
expect(missing.status).toBe(404);
// Indistinguishable — no data, and no reveal that the id exists.
expect(await foreign.json()).toEqual(await missing.json());
});
@@ -141,13 +154,124 @@ describe("POST /v1/channels/:channelId/messages", () => {
// sender chapter, so the guard fell back to `EITHER` and a user token was accepted
// here.
//
// So the repository test passed while the route it protects was open. A repository
// test proves a check exists; only a route test proves it fires.
describe("a private channel over the public route (FR-001, SC-002)", () => {
- const sendAs = async (token: string, channel: string, text = "hello") =>
+
+ // ══ THE SENDER (US2) ══════════════════════════════════════════
+
+ // ── T033: the four outcomes for an application credential ──────────────────
+ it("accepts a key's send naming a bot, and echoes the sender it used", async () => {
+ const res = await send({ text: "build 412 is green", user: "courier" });
+ expect(res.status).toBe(201);
+ // FR-009a: a caller now required to name a sender is told which was recorded.
+ expect((await res.json()).user).toBe("courier");
+ });
+
+ it("refuses a key's send naming a person with 403 sender_not_permitted", async () => {
+ const res = await send({ text: "posting as a human", user: "outsider" });
+ expect(res.status).toBe(403);
+ const body = (await res.json()) as { code: string; message: string };
+ // T032a: `ProtocolErrorFilter` maps a bare 403 to `forbidden`, and this is the one
+ // code in the chapter that collides with that ladder. The wire must carry the
+ // specific fact, not the generic one.
+ expect(body.code).toBe("sender_not_permitted");
+ expect(body.code).not.toBe("forbidden");
+ // And it names neither the person asked for nor the bots that would have worked.
+ expect(body.message).not.toContain("outsider");
+ expect(body.message).not.toContain("courier");
+ });
+
+ it("refuses a key's send naming nobody with 400 and the field", async () => {
+ const res = await send({ text: "who is this from?" });
+ expect(res.status).toBe(400);
+ expect((await res.json()).field).toBe("user");
+ });
+
+ it("refuses a foreign sender and a nonexistent one identically", async () => {
+ const foreign = await createEnvironment(createDb(createPool()), {
+ name: `messages-itest-foreign-${randomUUID().slice(0, 8)}`,
+ });
+ await new Repository(createDb(createPool()), foreign.id).upsertUser("theirs", {
+ kind: "bot",
+ description: "a bot of another tenant",
+ });
+
+ const a = await send({ text: "x", user: "theirs" });
+ const b = await send({ text: "x", user: "no-such-identifier-anywhere" });
+ expect(a.status).toBe(400);
+ expect(b.status).toBe(400);
+ // SC-005: the two answers must be indistinguishable, or naming an identifier is a
+ // way to ask whether another tenant has one.
+ expect(withoutRequestId(await a.json())).toEqual(
+ withoutRequestId(await b.json()),
+ );
+ });
+
+ // ── T034: a user token attributes to its subject and may name nobody ───────
+ it("attributes a user token's send to its subject", async () => {
+ const token = await tokenFor("insider");
+ const res = await fetch(`${url}/v1/channels/${channelId}/messages`, {
+ method: "POST",
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
+ body: JSON.stringify({ text: "from a person" }),
+ });
+ expect(res.status).toBe(201);
+ expect((await res.json()).user).toBe("insider");
+ });
+
+ it("refuses a body `user` beside a user token", async () => {
+ const token = await tokenFor("insider");
+ const res = await fetch(`${url}/v1/channels/${channelId}/messages`, {
+ method: "POST",
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
+ body: JSON.stringify({ text: "posting as someone else", user: "courier" }),
+ });
+ expect(res.status).toBe(400);
+ expect((await res.json()).field).toBe("user");
+ });
+
+ // ── T012c: the private channel, BOTH halves (SC-012) ───────────────────────
+ //
+ // A test that checked only the bot would pass if the membership gate had been deleted
+ // outright — which is the change that breaks the channel-control chapter's refusal. The pair is the
+ // oracle.
+ it("lets a key's bot send to a private channel it is not a member of", async () => {
+ const res = await send(
+ { text: "from the tenant's software", user: "courier" },
+ privateChannelId,
+ );
+ expect(res.status).toBe(201);
+ });
+
+ it("still refuses a person who is not a member of that private channel", async () => {
+ const token = await tokenFor("outsider");
+ const res = await sendAs(token, privateChannelId);
+ // 404, not 403: a private channel a caller cannot see answers as if absent
+ // (FR-019b).
+ expect(res.status).toBe(404);
+ });
+
+ // ── T012d: a bot may be banned (FR-005c, SC-013) ───────────────────────────
+ it("refuses a banned bot's send, indistinguishably from a foreign sender", async () => {
+ await repo.upsertUser("runaway", {
+ kind: "bot",
+ description: "posts far too often",
+ });
+ const banned = await repo.getUserByExternalId("runaway");
+ await repo.banUser(banned!.id);
+
+ const res = await send({ text: "still going", user: "runaway" });
+ // A ban stops a runaway integration without deleting the identity its messages are
+ // attributed to. The refusal is the ban's, which arrives before the channel is read.
+ expect(res.status).toBe(403);
+ expect((await res.json()).code).toBe("user_banned");
+ });
+
+ const sendAs = async (token: string, channel: string, text = "hello") =>
fetch(`${url}/v1/channels/${channel}/messages`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${token}`,
},
@@ -182,13 +306,13 @@ describe("POST /v1/channels/:channelId/messages", () => {
const token = await tokenFor("insider");
const accepted = await sendAs(token, privateChannelId, "mine to send");
expect(accepted.status).toBe(201);
});
it("accepts an application key's send to the same private channel (FR-005)", async () => {
- const accepted = await send({ text: "from the tenant" }, privateChannelId);
+ const accepted = await send({ text: "from the tenant", user: "courier" }, privateChannelId);
expect(accepted.status).toBe(201);
});
it("answers a non-member's history read exactly as an absent channel does", async () => {
// T041b. The history route dropped its caller the same way the send route did,
// and `listMessages` had no `userId` parameter to drop it INTO — so the task@@ -8,12 +8,24 @@ export const sendMessageBodySchema = z.strictObject({
text: z.string().min(1).max(8000),
metadata: z.record(z.string(), z.unknown()).optional(),
// Chapter 2.3 (FR-MSG-04): the client's idempotency key — minted at send
// time (FR-SDK-06), optional because server-originated messages may not
// carry one. The partial unique index (DR-03) ignores NULLs.
idempotency_key: z.string().uuid().optional(),
+ /** WHO IS SENDING (FR-MSG-15, FR-008).
+ *
+ * OPTIONAL HERE AND REQUIRED FOR ONE CREDENTIAL CLASS, which zod cannot express
+ * because it cannot see who is calling. A user token's send is attributed to the
+ * token's subject and naming a `user` in the body is refused; an application
+ * credential must name one, because it carries no user of its own. The controller
+ * resolves that per class (T029) and the service refuses what cannot be resolved.
+ *
+ * A CUSTOMER-SUPPLIED IDENTIFIER, not a platform id. Every other user-facing field in
+ * this API names a user the way FR-USR-01 says the customer does, and a route that
+ * took an internal uuid here would be the only one that did not. */
+ user: z.string().min(1).max(255).optional(),
});
export type SendMessageBody = z.infer<typeof sendMessageBodySchema>;
// The history query (chapter 2.4, FR-MSG-09): an opaque cursor, a
// direction, and a page size capped at 200. `limit` CLAMPS rather than@@ -154,13 +154,13 @@ describe("the outbox", () => {
text: "through the socket",
userId: tuan.id,
userExternalId: "tuan",
});
// The key-authenticated public send is unattributed (the credentials chapter's recorded bound),
// which is a CONTENT difference, not a shape one.
- await repo.sendMessage(channelId, { text: "through REST" });
+ await repo.sendMessage(channelId, { text: "through REST", userId: tuan.id });
const rows = (await unpublishedFor(db, env.id)).slice(before.length);
expect(rows.length).toBe(2);
const shapes = rows.map((r) => Object.keys(r.payload).sort().join(","));
expect(shapes[0]).toBe(shapes[1]);
expect(rows[0]!.payload.data.user).toBe("tuan");
expect(rows[1]!.payload.data.user).toBeNull();@@ -1,11 +1,12 @@
import "reflect-metadata";
import { Test } from "@nestjs/testing";
import type { INestApplication } from "@nestjs/common";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { randomUUID } from "node:crypto";
import { AppModule } from "../app.module";
import { createDb, createPool, type Db } from "../db/client";
import { mintUserToken } from "../auth/user-token";
import {
createApiKey,
@@ -493,13 +494,20 @@ describe("a user's channel listing", () => {
const body = (await (await profile("profiled")).json()) as {
external_id: string;
display_name: string | null;
avatar_url: string | null;
metadata: Record<string, unknown>;
};
+ // THE PROFILE GREW TWO FIELDS AND THIS ASSERTION BROKE, WHICH IS WHY IT IS EXACT
+ // (T021a). `toEqual` on a whole body is the only assertion that
+ // notices a field arriving — a `toMatchObject` would have said nothing, and a
+ // reader would have learned about `kind` from the code rather than from a test.
+ // The user-surface chapter made the same trade for `last_message`.
expect(body).toEqual({
+ kind: "person",
+ description: null,
external_id: "profiled",
display_name: "After",
avatar_url: "https://cdn.example.com/a/b.png",
metadata: { team: "support", tier: 3 },
});
});
@@ -571,12 +579,118 @@ describe("a user's channel listing", () => {
});
it("answers 404 for a user this tenant does not have", async () => {
expect((await profile("nobody-at-all")).status).toBe(404);
});
+ // ══ A BOT IS A USER (US3, FR-004) ═════════════════════════════
+
+ // ── T043: a bot inherits everything keyed on a user ───────────────────────
+ it("a bot can be a channel member with a role, and appears in the member list", async () => {
+ const channel = await repo.createChannel(`botmember-${randomUUID().slice(0, 8)}`, "public");
+ const bot = (
+ await repo.upsertUser("member-bot", {
+ kind: "bot",
+ description: "sits in the channel and posts",
+ })
+ ).user;
+ await repo.addMember(channel.id, bot.id);
+
+ // `listMembers` returns user ids; the ROLE is on the add's own response, which is
+ // where the channel-control chapter put it (read back rather than echoed).
+ expect(await repo.listMembers(channel.id)).toContain(bot.id);
+ // A ROLE LIKE ANYBODY ELSE. FR-004 asks that a bot support the operations a person
+ // supports, and membership with a role is one of them — nothing about `kind` reaches
+ // the members table, so re-adding reports the role it already holds.
+ // `addMember` reports an OUTCOME, not a role — the role lives on the HTTP response
+ // What matters here is that a second add of a bot behaves exactly
+ // as a second add of a person: nothing about `kind` reaches the members table.
+ expect(await repo.addMember(channel.id, bot.id)).toBe("already_a_member");
+ });
+
+ // ── T043a: a bot's own channel listing, and its unread count ──────────────
+ it("answers a bot's channel listing, with the whole history unread", async () => {
+ const channel = await repo.createChannel(`botlist-${randomUUID().slice(0, 8)}`, "public");
+ const bot = (
+ await repo.upsertUser("listing-bot", {
+ kind: "bot",
+ description: "has a listing like any user",
+ })
+ ).user;
+ await repo.addMember(channel.id, bot.id);
+ const person = await repo.createUser(`p-${randomUUID().slice(0, 8)}`);
+ await repo.addMember(channel.id, person.id);
+ await repo.sendMessage(channel.id, { text: "one", userId: person.id });
+ await repo.sendMessage(channel.id, { text: "two", userId: person.id });
+
+ const listed = await repo.listChannelsForUser(bot.id, { limit: 10 });
+ const row = listed.rows.find((r) => r.id === channel.id);
+ expect(row).toBeDefined();
+ // THE WHOLE HISTORY IS UNREAD, AND IT ALWAYS WILL BE. Nothing acknowledges on a
+ // bot's behalf — there is no client holding its token, because it has none. Worth
+ // asserting rather than assuming: a reader who saw `unread: 2` might go looking for
+ // the acknowledgement path a bot does not have.
+ expect(row!.unread).toBe(2);
+ });
+
+ // ── T044: banned, then deleted, with the messages surviving ───────────────
+ it("bans a bot, refuses its sends, then deletes it with its messages intact", async () => {
+ const channel = await repo.createChannel(`botlife-${randomUUID().slice(0, 8)}`, "public");
+ const bot = (
+ await repo.upsertUser("mortal-bot", {
+ kind: "bot",
+ description: "will be banned and then deleted",
+ })
+ ).user;
+ await repo.addMember(channel.id, bot.id);
+ const sent = await repo.sendMessage(channel.id, {
+ text: "I was here",
+ userId: bot.id,
+ });
+
+ await repo.banUser(bot.id);
+ await expect(
+ repo.sendMessage(channel.id, { text: "after the ban", userId: bot.id }),
+ ).rejects.toThrow();
+
+ expect((await removeUser("mortal-bot")).status).toBe(200);
+ // SC-007: the messages survive and still name it. A bot's history is the record of
+ // what the customer's software did, and deleting the identity must not rewrite it.
+ const history = await repo.listMessages(channel.id, { limit: 10 });
+ const still = history.find((m) => m.id === sent.id);
+ expect(still).toBeDefined();
+ expect(still!.user).toBe("mortal-bot");
+ });
+
+ // ── T044a: the description survives deletion, and the revival ─────────────
+ //
+ // THIS IS THE ASSERTION THAT WOULD HAVE CAUGHT THE COLLISION. `deleteUser` clears
+ // `display_name`, `avatar_url` and `metadata`; adding `description` to that list by
+ // symmetry would violate `users_bot_description_check` and make a bot the one kind of
+ // user that cannot be deleted at all.
+ it("keeps a deleted bot's description, and revives it with the description intact", async () => {
+ await upsert([
+ { external_id: "revivable-bot", kind: "bot", description: "says what it does" },
+ ]);
+ expect((await removeUser("revivable-bot")).status).toBe(200);
+
+ const deleted = await repo.getUserByExternalId("revivable-bot");
+ expect(deleted?.deleted_at).not.toBeNull();
+ expect(deleted?.description).toBe("says what it does");
+ expect(deleted?.display_name).toBeNull();
+
+ const revived = await upsert([
+ { external_id: "revivable-bot", kind: "bot", description: "says what it does" },
+ ]);
+ expect((await revived.json()).data[0]).toMatchObject({
+ status: "revived",
+ kind: "bot",
+ description: "says what it does",
+ });
+ });
+
// ══ THE BULK UPSERT AND THE DELETION (FR-025 to FR-030, SC-012) ═════════════
const upsert = (users: unknown, key = credential) =>
fetch(`${url}/v1/users`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
@@ -586,12 +700,207 @@ describe("a user's channel listing", () => {
const removeUser = (user: string, key = credential) =>
fetch(`${url}/v1/users/${user}`, {
method: "DELETE",
headers: { authorization: `Bearer ${key}` },
});
+ // ══ THE BOT USER (FR-USR-07) ══════════════════════════════════
+
+ // ── T022: the round trip ────────────────────────────────────────────────────
+ it("creates a bot with a description, reads it back, and edits the description", async () => {
+ const created = await upsert([
+ {
+ external_id: "deploy-bot",
+ display_name: "Deploy Bot",
+ kind: "bot",
+ description: "posts a line when a deploy finishes",
+ },
+ ]);
+ expect(created.status).toBe(200);
+ expect((await created.json()).data[0]).toMatchObject({
+ external_id: "deploy-bot",
+ status: "created",
+ kind: "bot",
+ description: "posts a line when a deploy finishes",
+ });
+
+ const read = await (await profile("deploy-bot")).json();
+ expect(read.kind).toBe("bot");
+ expect(read.description).toBe("posts a line when a deploy finishes");
+
+ const edited = await patchProfile("deploy-bot", {
+ description: "posts a line when a deploy finishes, and when one fails",
+ });
+ expect(edited.status).toBe(200);
+ expect((await (await profile("deploy-bot")).json()).description).toBe(
+ "posts a line when a deploy finishes, and when one fails",
+ );
+ });
+
+ // ── T023: refused at the boundary, and by the database ──────────────────────
+ it("refuses a bot with no description, naming the field", async () => {
+ const res = await upsert([{ external_id: "no-why", kind: "bot" }]);
+ expect(res.status).toBe(400);
+ const body = await res.json();
+ expect(body.field).toContain("description");
+ });
+
+ it("refuses a description on a person", async () => {
+ const res = await upsert([
+ { external_id: "a-person", description: "people do not have these" },
+ ]);
+ expect(res.status).toBe(400);
+ expect((await res.json()).field).toContain("description");
+ });
+
+ // ── T025: `description: null` is refused on BOTH kinds (SC-014) ─────────────
+ //
+ // THE ASSERTION IS THE STATUS, NOT THE DATABASE. A test that checked the row was
+ // unchanged would pass when the request 500s and the transaction rolls back, which is
+ // the failure this test exists to tell apart from success.
+ it("refuses `description: null` on a bot — the CHECK must never be reached", async () => {
+ await upsert([
+ { external_id: "null-bot", kind: "bot", description: "here for now" },
+ ]);
+ const res = await patchProfile("null-bot", { description: null });
+ expect(res.status).toBe(400);
+ expect((await (await profile("null-bot")).json()).description).toBe("here for now");
+ });
+
+ it("refuses `description: null` on a person too, where it would mean nothing", async () => {
+ await upsert([{ external_id: "null-person" }]);
+ expect((await patchProfile("null-person", { description: null })).status).toBe(400);
+ });
+
+ // ── T018c: the promotion, in all three states ──────────────────────────────
+ it("promotes a person to a bot when the row has never sent a message", async () => {
+ await upsert([{ external_id: "grew-up" }]);
+ const res = await upsert([
+ { external_id: "grew-up", kind: "bot", description: "it was a person first" },
+ ]);
+ expect(res.status).toBe(200);
+ expect((await res.json()).data[0]).toMatchObject({
+ status: "updated",
+ kind: "bot",
+ description: "it was a person first",
+ });
+ });
+
+ it("refuses the promotion once the row has sent a message", async () => {
+ await upsert([{ external_id: "has-spoken" }]);
+ const speaker = (await (await profile("has-spoken")).json()) as {
+ external_id: string;
+ };
+ const channel = await repo.createChannel(`spoken-${randomUUID().slice(0, 8)}`, "public");
+ const row = await repo.getUserByExternalId(speaker.external_id);
+ await repo.sendMessage(channel.id, { text: "I said something", userId: row!.id });
+
+ const res = await upsert([
+ { external_id: "has-spoken", kind: "bot", description: "too late" },
+ ]);
+ expect(res.status).toBe(200);
+ expect((await res.json()).data[0]).toMatchObject({
+ status: "kind_conflict",
+ kind: "person",
+ });
+ });
+
+ it("refuses bot -> person unconditionally, even with no messages", async () => {
+ await upsert([
+ { external_id: "stays-a-bot", kind: "bot", description: "cannot be demoted" },
+ ]);
+ const res = await upsert([{ external_id: "stays-a-bot", kind: "person" }]);
+ expect(res.status).toBe(200);
+ expect((await res.json()).data[0]).toMatchObject({
+ status: "kind_conflict",
+ kind: "bot",
+ });
+ });
+
+ // ── T018d: the trap, in the order a customer hits it ───────────────────────
+ //
+ // This is the assertion the escape exists for. `POST /v1/channels/:id/members` creates
+ // an unknown identifier as a PERSON, because `createUser` cannot set `kind`. A customer
+ // who adds their bot to a channel before registering it would, without FR-002d, have
+ // made that bot permanently impossible.
+ it("survives adding the bot to a channel BEFORE registering it as a bot", async () => {
+ const channel = await repo.createChannel(`trap-${randomUUID().slice(0, 8)}`, "public");
+ const stranger = await repo.createUser("support-bot", "support-bot");
+ await repo.addMember(channel.id, stranger.id);
+ expect((await repo.getUserByExternalId("support-bot"))!.kind).toBe("person");
+
+ const res = await upsert([
+ { external_id: "support-bot", kind: "bot", description: "answers tickets" },
+ ]);
+ expect((await res.json()).data[0]).toMatchObject({
+ status: "updated",
+ kind: "bot",
+ });
+ });
+
+ // ── T024a: omitting `kind` while editing a bot must not demote it ──────────
+ //
+ // The case FR-002b exists for, and the one a `.default("person")` in the request
+ // schema silently breaks: absent would become 'person', the entry would read as a
+ // demotion, and editing a bot's description through the upsert would be impossible.
+ it("leaves a bot a bot when the entry omits `kind`", async () => {
+ await upsert([
+ { external_id: "quiet-bot", kind: "bot", description: "first description" },
+ ]);
+ const res = await upsert([
+ { external_id: "quiet-bot", display_name: "Quiet Bot" },
+ ]);
+ expect((await res.json()).data[0]).toMatchObject({
+ status: "updated",
+ kind: "bot",
+ description: "first description",
+ });
+ });
+
+ // ── T024: a conflict in one entry does not fail the other ninety-nine ──────
+ it("reports kind_conflict per entry, in a 200, with the other entries written", async () => {
+ await upsert([
+ { external_id: "conflict-bot", kind: "bot", description: "a bot already" },
+ ]);
+ const res = await upsert([
+ { external_id: "batch-a" },
+ { external_id: "conflict-bot", kind: "person" },
+ { external_id: "batch-b" },
+ ]);
+ expect(res.status).toBe(200);
+ const data = (await res.json()).data as Array<{ status: string }>;
+ expect(data.map((d) => d.status)).toEqual([
+ "created",
+ "kind_conflict",
+ "created",
+ ]);
+ expect((await profile("batch-a")).status).toBe(200);
+ expect((await profile("batch-b")).status).toBe(200);
+ });
+
+ // ── T023a: the status set, pinned ──────────────────────────────────────────
+ //
+ // `codes.ts` pins error codes and close codes the same way, and close code 4003 is the
+ // precedent for why: an exact set makes a fifth value a decision rather than an
+ // accident. This assertion is what will fail on the build that adds one.
+ it("the upsert's status set is exactly these four", async () => {
+ await upsert([
+ { external_id: "pinned-bot", kind: "bot", description: "for the pin" },
+ ]);
+ const res = await upsert([
+ { external_id: "pinned-new" },
+ { external_id: "pinned-bot", kind: "person" },
+ ]);
+ const seen = new Set(
+ ((await res.json()).data as Array<{ status: string }>).map((d) => d.status),
+ );
+ for (const status of seen) {
+ expect(["created", "updated", "revived", "kind_conflict"]).toContain(status);
+ }
+ });
+
// ── T138: 100 accepted, 101 refused (SC-012) ────────────────────────────────
it("upserts 100 users in one request", async () => {
const entries = Array.from({ length: 100 }, (_, i) => ({
external_id: `bulk-${i}`,
display_name: `Bulk ${i}`,
}));
@@ -737,13 +1046,17 @@ describe("a user's channel listing", () => {
avatar_url: string | null;
metadata: Record<string, unknown>;
};
// THE SAME ROW, EMPTY. `(environment_id, external_id)` is unique and the row never
// left, so there is no other honest answer than reusing it — and a revived row does
// not inherit the profile the deletion wiped.
+ // The revival's shape, exact for T021a's reason above. A revived user is a person
+ // with no description unless something said otherwise.
expect(back).toEqual({
+ kind: "person",
+ description: null,
external_id: "revivable",
display_name: null,
avatar_url: null,
metadata: {},
});
const after = await repo.getUserByExternalId("revivable");@@ -50,12 +50,31 @@ const userMetadataSchema = z
* name; `{}` leaves it. Both columns are nullable, so the API can express the difference
* and a PATCH that could only set would leave a customer unable to undo one. */
export const userProfileBodySchema = z.strictObject({
display_name: z.string().min(1).max(255).nullable().optional(),
avatar_url: z.string().url().max(2048).nullable().optional(),
metadata: userMetadataSchema.optional(),
+ /** A bot's description, editable here (FR-004).
+ *
+ * **NOT `.nullable()`, AND THIS COMMENT IS WHY IT STAYS THAT WAY.** Every field above
+ * is nullable on purpose and the paragraph above says what that means: `null` clears.
+ * Extending the idiom one more line would have been the natural thing to write, and
+ * `PATCH {"description": null}` would then set null on a bot,
+ * `users_bot_description_check` would raise, and the customer would get a **500** for
+ * a request the boundary should have refused (FR-004b).
+ *
+ * Nullability buys nothing for either kind. A bot must never clear its description —
+ * the constraint forbids it, and a bot whose description is gone is the anonymous
+ * sender this chapter exists to remove. A person may never be given one
+ * (`upsertUserEntrySchema` refuses that). So the field is settable and not clearable,
+ * and the next person to reach for symmetry has to read this first.
+ *
+ * `kind` IS ABSENT FROM THIS SCHEMA, and `strictObject` is what refuses it: a
+ * promotion is a decision about a stored row, so it goes through the upsert where the
+ * per-entry status can report a conflict. A PATCH has one row and one status code. */
+ description: z.string().min(1).max(2000).optional(),
});
export type UserProfileBody = z.infer<typeof userProfileBodySchema>;
/** An entry in the bulk upsert (FR-025, FR-026).
*
@@ -63,18 +82,78 @@ export type UserProfileBody = z.infer<typeof userProfileBodySchema>;
* **updates** it, so the entry carries what there is to update. An entry that was only an
* external id could not distinguish "create this user" from "update nothing about them".
*
* `strictObject`, and the same 4 KB metadata bound and URL validation the single PATCH
* uses — one schema fragment, so the two routes cannot drift into accepting different
* things for the same column. */
-export const upsertUserEntrySchema = z.strictObject({
- external_id: z.string().min(1).max(255),
- display_name: z.string().min(1).max(255).nullable().optional(),
- avatar_url: z.string().url().max(2048).nullable().optional(),
- metadata: userMetadataSchema.optional(),
-});
+export const upsertUserEntrySchema = z
+ .strictObject({
+ external_id: z.string().min(1).max(255),
+ display_name: z.string().min(1).max(255).nullable().optional(),
+ avatar_url: z.string().url().max(2048).nullable().optional(),
+ metadata: userMetadataSchema.optional(),
+ /** What kind of thing this user is (FR-USR-07).
+ *
+ * NO `.default("person")`, AND THAT IS THE REQUIREMENT (FR-002b). A schema default
+ * would make "absent" indistinguishable from "person" before anything can compare
+ * the entry to the stored row — and an entry that omits `kind` for an existing bot
+ * is asking for no change, not asking to demote it. The default belongs at
+ * creation, in the column (`schema.ts`), where only a new row gets it. */
+ kind: z.enum(["person", "bot"]).optional(),
+ /** What the software is, and why it posts.
+ *
+ * NOT `.nullable()`, unlike every sibling above, and `userProfileBodySchema`'s
+ * comment explains the idiom this deliberately breaks: there, `null` clears. Here
+ * a null description on a bot violates `users_bot_description_check` and would
+ * reach the customer as a 500 (FR-004b). The field is settable and not clearable. */
+ description: z.string().min(1).max(2000).optional(),
+ })
+ /** THE TWO RULES ZOD CAN CHECK WITHOUT THE STORED ROW (FR-002, FR-004b).
+ *
+ * A bot needs a description and a person may not have one — both decidable from the
+ * request alone, which is why they live here and not in the service. What zod
+ * CANNOT decide is whether a change of `kind` is permitted, because that depends on
+ * the row already in the database and on whether it has ever sent a message; that is
+ * `kind_conflict`, a per-entry status in a 200 (FR-002a).
+ *
+ * The line between the two: a refusal a customer can fix by re-reading their own
+ * request belongs at the boundary and fails the whole batch, the way a bad
+ * `avatar_url` or an unknown key already does. A refusal that depends on state they
+ * cannot see is reported per entry, so one bad row out of a hundred does not fail
+ * the other ninety-nine. */
+ .superRefine((entry, ctx) => {
+ if (entry.kind === "bot" && entry.description === undefined) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["description"],
+ message: "a bot requires a description",
+ });
+ }
+ // A DESCRIPTION REQUIRES `kind: "bot"` IN THE SAME ENTRY, and the condition is
+ // `!== "bot"` rather than `=== "person"` for a reason found by a test.
+ //
+ // The first version read `entry.kind === "person"`, which let
+ // `{external_id, description}` through — `kind` absent is the common shape, not
+ // `kind: "person"`, so the rule never fired on the case it was written for and a
+ // person could be given a description.
+ //
+ // So the two rules together make description and `kind: "bot"` imply each other
+ // WITHIN AN ENTRY. Editing a bot's description through this route means restating
+ // `kind: "bot"`, which is not a change and raises no conflict; the single PATCH is
+ // where a description is edited on its own (FR-004). The alternative — inferring
+ // permission from the stored row — is exactly the decision zod cannot make, and
+ // moving it here would put a state-dependent refusal at the boundary where it
+ // fails a whole batch of a hundred.
+ if (entry.description !== undefined && entry.kind !== "bot") {
+ ctx.addIssue({
+ code: "custom",
+ path: ["description"],
+ message: "a description belongs to a bot; name `kind: \"bot\"` with it",
+ });
+ }
+ });
/** FR-025's bound: 100 in one request, and `field: "users"` on 101.
*
* THE SAME 100 AS THE MEMBER-ADD AND THE REMOVAL, for the same reason: all three are "how
* much a customer's server may hand over in one call", and three different ceilings would
* be three numbers to remember for one idea.@@ -42,18 +42,27 @@ export class UsersService {
* would be returning a field whose only possible value is null. */
private static profile(user: UserRow): {
external_id: string;
display_name: string | null;
avatar_url: string | null;
metadata: Record<string, unknown>;
+ kind: "person" | "bot";
+ description: string | null;
} {
return {
external_id: user.external_id,
display_name: user.display_name,
avatar_url: user.avatar_url,
metadata: user.metadata,
+ // `kind` ON EVERY USER, AND THAT IS FR-003 BEING SATISFIED RATHER THAN
+ // DOCUMENTED. A client that had to infer personhood from a null
+ // description would be inferring it from an absence, and the clause asks for a
+ // stored property. `description` is null for a person because the schema refuses
+ // to give one, not because nobody has set it yet.
+ kind: user.kind,
+ description: user.description,
};
}
async readProfile(externalId: string): Promise<ReturnType<typeof UsersService.profile>> {
return UsersService.profile(await this.requireUser(externalId));
}
@@ -171,28 +180,42 @@ export class UsersService {
* rejected the whole body before any write, so what remains here are failures the
* database raises, which per-entry reporting is the right shape for.
*/
async upsertUsers(body: UpsertUsersBody): Promise<{
data: Array<{
external_id: string;
- status: "created" | "updated" | "revived";
+ /** A FOURTH STATUS, IN A 200 (FR-002a). `kind_conflict` says the
+ * entry asked to change what kind of thing a user is and the change was refused
+ * — a promotion whose row has already sent a message, or any demotion.
+ *
+ * NOT A 400, and the reason is the shape of this route rather than politeness.
+ * Zod cannot see the stored row, so this refusal is only knowable mid-batch; a
+ * status code would fail all hundred entries because of entry 7, which is what
+ * this per-entry array exists to prevent. The boundary keeps the refusals a
+ * customer can fix by re-reading their own request — a bot with no description,
+ * a person with one — and those still fail the whole body. */
+ status: "created" | "updated" | "revived" | "kind_conflict";
display_name: string | null;
avatar_url: string | null;
metadata: Record<string, unknown>;
+ kind: "person" | "bot";
+ description: string | null;
}>;
}> {
const data = [];
for (const entry of body.users) {
const { external_id, ...profile } = entry;
const { user, status } = await this.repo.upsertUser(external_id, profile);
data.push({
external_id: user.external_id,
status,
display_name: user.display_name,
avatar_url: user.avatar_url,
metadata: user.metadata,
+ kind: user.kind,
+ description: user.description,
});
}
return { data };
}
/** Delete a user (FR-027 to FR-029).@@ -51,12 +51,19 @@ interface Seeder {
userId?: string;
userExternalId?: string;
metadata?: Record<string, unknown>;
idempotencyKey?: string;
},
) => Promise<{ id: string; seq: number }>;
+ /** The gateway declares its own narrow view of the repository —
+ * it has no database and must not gain one (research R12) — so a new fixture
+ * capability means one more line here. */
+ upsertUser: (
+ externalId: string,
+ profile: { kind?: "person" | "bot"; description?: string },
+ ) => Promise<{ status: string }>;
};
}
export interface SocketTenant {
environmentId: string;
credential: string;
@@ -67,12 +74,20 @@ export interface SocketTenant {
* member of. */
privateChannelId: string;
/** That private channel's history, read with the APPLICATION key — which sees
* private channels (FR-005) — so a refused send can be checked against the
* rows rather than against its own error frame. */
privateHistory: () => Promise<string>;
+ /** A DISPOSABLE user of this tenant, with its own token, that a test may destroy
+ * (T040b).
+ *
+ * NOT the tenant's own user. Promoting that one to a bot makes it unable to connect
+ * for the rest of the file, and every test after it — including the control — fails.
+ * That is the fifth time in two features a shared fixture has been the hazard, and
+ * the fix is a fixture nobody else depends on rather than a rule nobody remembers. */
+ disposable: () => Promise<{ token: string; promoteToBot: () => Promise<unknown> }>;
/** Removes this tenant's user from its own channel via the public route. */
removeSelf: () => Promise<void>;
rejoinSelf: () => Promise<void>;
archiveOwnChannel: () => Promise<void>;
unarchiveOwnChannel: () => Promise<void>;
/** A user and a channel nobody else in the suite touches, with one attributed message
@@ -205,12 +220,28 @@ export async function seedSocketTenants(): Promise<SocketTenants> {
// Minted through the api rather than signed here: the signing secret never
// leaves the api (research R1), which is also why the gateway asks the api to
// verify rather than verifying itself.
token: await mintToken(api.url, key.credential, userExternalId),
say: (text: string) =>
repo.sendMessage(channel.id, { text, userId: user.id, userExternalId }),
+ /** Turn this tenant's own user into a bot (T040b). Exposed rather
+ * than done in the test, because the fixture owns the repository handle and the
+ * test has no database of its own. */
+ disposable: async () => {
+ const who = `${label}-disposable-${Math.random().toString(36).slice(2, 8)}`;
+ const row = await repo.createUser(who, "Disposable");
+ await repo.addMember(channel.id, row.id);
+ return {
+ token: await mintToken(api.url, key.credential, who),
+ promoteToBot: () =>
+ repo.upsertUser(who, {
+ kind: "bot",
+ description: "promoted while holding a live token",
+ }),
+ };
+ },
/** Remove this tenant's own user from its own public channel, through the
* public route — so the test asserts the consequence of the API rather than of
* a direct write. */
removeSelf: async () => {
const res = await fetch(
`${api.url}/v1/channels/${channel.id}/members/remove`,@@ -166,12 +166,74 @@ describe("the socket refuses another tenant's identifiers", () => {
const types = declaredFrameTypes();
// An empty derivation is a broken derivation, not a small protocol.
expect(types.length).toBeGreaterThan(1);
expect(types).toContain("message.send");
});
+ // ── T040b: a promoted bot's live token cannot open a socket (FR-005b) ──────
+ //
+ // REFUSING AT THE MINT IS NOT ENOUGH, and this is the test that says so. A token lives
+ // up to 24 hours (FR-AUT-07), so a user promoted to a bot at 09:00 holds a valid token
+ // until 09:00 tomorrow. The session route reads `banned_at` and, until this chapter,
+ // not `kind` — so closing the mint alone would leave a bot able to connect for a day
+ // after it became one.
+ //
+ // THE SOCKET SEES A CLOSE, NOT A 404. The refusal is the api's, at
+ // `POST /internal/session`, and the gateway has nothing to tell a client whose session
+ // was refused — which is why this test lives here and not in the api's suite.
+ describe("a bot cannot open a socket, even holding a token minted before it was one", () => {
+ it("closes the connection instead of acknowledging it", async () => {
+ // A DISPOSABLE USER, not the tenant's own. Promoting the attacker's user makes it
+ // unable to connect for the rest of the file, and the first version of this test
+ // took the control down with it — the fifth shared-fixture casualty in two
+ // features.
+ const doomed = await t.attacker.disposable();
+ // The token is minted while the identifier is still a person, which is the whole
+ // scenario: the promotion happens afterwards and the token stays valid.
+ await connect(url, doomed.token).waitFor("connection.ack");
+
+ await doomed.promoteToBot();
+
+ await expect(
+ connect(url, doomed.token).waitFor("connection.ack"),
+ ).rejects.toThrow();
+ });
+ });
+
+ // ── THE CONTROL, for the reason the HTTP gauntlet needed one ────────────────
+ //
+ // Three of the four attacks below assert that NOTHING happened. A socket that
+ // is broken, a token that is expired, a gateway that delivers to nobody — all
+ // of those also make nothing happen, and would pass this file while attacking
+ // nothing. So the attacker's socket is shown to work first.
+ describe("the control: the attacker's own socket works", () => {
+ it("connects and is acknowledged", async () => {
+ const ack = await connect(url, t.attacker.token).waitFor<{ payload: { user: string } }>(
+ "connection.ack",
+ );
+ expect(ack.payload.user).toBe(t.attacker.userExternalId);
+ });
+
+ it("sends into its own channel and is acked", async () => {
+ const client = connect(url, t.attacker.token);
+ await client.waitFor("connection.ack");
+ client.socket.send(
+ JSON.stringify({
+ type: "message.send",
+ payload: {
+ idem_key: randomUUID(),
+ channel: t.attacker.channelId,
+ text: "the control writes",
+ },
+ }),
+ );
+ const ack = await client.waitFor<{ payload: { seq: number } }>("message.ack");
+ expect(ack.payload.seq).toBeGreaterThan(0);
+ });
+ });
+
it("a connection ack names nothing belonging to the other tenant", async () => {
const socket = connect(url, t.attacker.token);
const ack = await socket.waitFor("connection.ack");
const serialised = JSON.stringify(ack);
expect(serialised).not.toContain(t.victim.channelId);
expect(serialised).not.toContain(t.victim.environmentId);@@ -225,51 +225,107 @@ describe("a channel, a member and a message, all over the public API", () => {
// ever — so the guide has to say "send over the socket", not "send a message".
//
// FIXING IT IS A PRODUCT DECISION AND NOT THIS CHAPTER'S. Attributing a public
// send to an end-user token would change what `user` means on the wire for every
// existing caller (FR-MSG-13's territory), and a live fan-out from the api is a
// new coupling between the api and Redis. Both are named in the chapter.
+ //
+ // THE SENDER CHAPTER DID HALF OF THAT, and this comment is left standing rather than
+ // rewritten because the half it did is not the half that fixes this. FR-MSG-13 was
+ // amended — "on behalf of any user" became "on behalf of a bot user of that tenant" —
+ // so a REST send now names a sender, and the send below names one. What did NOT change
+ // is the fan-out: the api still publishes nothing, so the message still reaches no
+ // socket, live or on resume. The fan-out chapter is the fan-out.
+ //
+ // THE SENDER IS A BOT, because the caller is a key. A key may not name "tuan" — that
+ // is a person and `sender_not_permitted` is the refusal — so the send that this test
+ // needs to succeed must name software.
it("does NOT deliver a REST-sent message, live or on resume", async () => {
const channelId = await seedOverTheWire("rest", ["tuan"]);
const token = await mint("tuan");
+ // Created over the public route, because this suite has no database handle by
+ // design — it is the one that tests what a customer can reach.
+ await post(
+ "/v1/users",
+ {
+ users: [
+ {
+ external_id: "rest-courier",
+ kind: "bot",
+ description: "sends over REST so this test can watch nothing arrive",
+ },
+ ],
+ },
+ api.credential,
+ );
const live = reader(`${wsUrl}/v1/ws?token=${token}`);
await live.opened;
const first = `first over rest ${randomUUID().slice(0, 8)}`;
const second = `second over rest ${randomUUID().slice(0, 8)}`;
for (const text of [first, second]) {
- const sent = await post(`/v1/channels/${channelId}/messages`, { text }, api.credential);
+ const sent = await post(
+ `/v1/channels/${channelId}/messages`,
+ { text, user: "rest-courier" },
+ api.credential,
+ );
expect(sent.status).toBe(201);
}
// Both rows exist and both are unattributed — this is the row shape the drop
// is about, asserted rather than assumed.
const history = (await (
await fetch(`${api.url}/v1/channels/${channelId}/messages?limit=10`, {
headers: { authorization: `Bearer ${api.credential}` },
})
).json()) as { messages: { seq: number; user: string | null; text: string }[] };
expect(history.messages.map((m) => m.text)).toEqual([second, first]);
- expect(history.messages.every((m) => m.user === null)).toBe(true);
+ // WAS `every((m) => m.user === null)`, AND THAT IS THE CHAPTER
+ // (T055's class). This assertion existed to prove the rows were senderless, which was
+ // why `toFrame` dropped them. Every REST send now names a sender, so the premise it
+ // rested on is gone.
+ expect(history.messages.every((m) => m.user === "rest-courier")).toBe(true);
// No live delivery.
await new Promise((resolve) => setTimeout(resolve, 1_500));
expect(live.frames.filter((f) => f.type === "message.created")).toEqual([]);
live.socket.close();
- // And none on resume either. The cursor IS accepted — `resume_ok` is true and
- // the channel is in the echoed cursor — so this is not a rejected resume
- // dressed as an empty one. The page came back and every row in it was
- // dropped for having no sender.
+ // AND ON RESUME IT NOW ARRIVES — WHICH IS HALF OF THE GAP CLOSING.
+ //
+ // This block asserted `[]`, and the comment said why: "the page came back and every
+ // row in it was dropped for having no sender." That was true, and it is the reason
+ // the isolation harness's `gaps.md` G1 listed TWO independent mechanisms for "a REST-sent
+ // message reaches no socket" — nothing publishes, and the public send passes no user.
+ //
+ // FR-MSG-15 removes the second. Every REST send now names a sender, `toFrame` has no
+ // reason to drop the row, and the backfill delivers it. So the resume half of G1 is
+ // closed by this chapter and the LIVE half is not: `live.frames` above is still
+ // empty, because only the gateway publishes to the fan-out (`session.ts`) and the api
+ // still publishes nothing. The fan-out chapter is that half.
+ //
+ // The test's name is now half wrong and is left alone deliberately: T096a amends the
+ // gap record, and renaming a test is not how a reader learns that a two-mechanism
+ // gap became a one-mechanism gap.
const resumed = reader(`${wsUrl}/v1/ws?token=${token}&cursor=${channelId}:1`);
await resumed.opened;
await new Promise((resolve) => setTimeout(resolve, 1_500));
const ack = resumed.frames.find((f) => f.type === "connection.ack") as
| { payload: { cursor: Record<string, number>; resume_ok: boolean } }
| undefined;
expect(ack?.payload.resume_ok).toBe(true);
expect(Object.keys(ack?.payload.cursor ?? {})).toContain(channelId);
- expect(resumed.frames.filter((f) => f.type === "message.created")).toEqual([]);
+ // ONE FRAME, NOT TWO, AND THE CURSOR IS WHY. `cursor=${channelId}:1` says "I have
+ // seen through sequence 1", so the backfill replays what came after it — the second
+ // message only. Asserting two was an assumption about the fixture rather than a
+ // reading of the cursor.
+ const onResume = resumed.frames.filter((f) => f.type === "message.created");
+ expect(onResume).toHaveLength(1);
+ const frame = onResume[0] as unknown as {
+ payload: { user: string; text: string };
+ };
+ expect(frame.payload.text).toBe(second);
+ expect(frame.payload.user).toBe("rest-courier");
resumed.socket.close();
}, 60_000);
});@@ -129,21 +129,39 @@ export default defineConfig({
// did not change in between.
//
// WHAT IS STILL UNCOVERED, and each is the class the note above names — a
// throw for a state the surrounding code says cannot arise:
//
// 119 no such environment, in a mint whose caller already resolved it
- // 805 a channel neither inserted nor readable: the loser of an ON
+ // 841 a channel neither inserted nor readable: the loser of an ON
// CONFLICT race finding no row, which needs the winner's row deleted
// between two statements of one call, and nothing deletes channels
- // 1899 an idempotency key that conflicted while its message is missing
- // 2060 the private-channel arm of the history read, whose OTHER arm every
+ // 2109 an idempotency key that conflicted while its message is missing
+ // 2270 the private-channel arm of the history read, whose OTHER arm every
// test takes — the one branch here that is reachable, and the chapter
// that gives a user a history page is where it gets its case
+ //
+ // The four are the same four; only their line numbers moved. Re-read at the
+ // sender chapter rather than assumed, because a stale list of what is uncovered
+ // is how a ratchet keeps a claim nobody has checked since it was true.
"services/api/src/db/repository.ts": {
- branches: 90,
+ // 90 -> 92 (the sender chapter). MEASURED TWICE ON THIS TREE: 92.66 both
+ // times, byte-identical down to the uncovered line numbers. Two observations
+ // rather than one because coverage is not reproducible run to run here —
+ // `session.ts` has read 87.80 and 85.36 on identical code twenty minutes
+ // apart — and a ratchet pinned to a single lucky reading is one that teaches
+ // its next reader to lower it. This file did not move at all, so the headroom
+ // below the pin is 0.66 rather than a swing allowance.
+ //
+ // The arms that moved it: the sender's `kind` read feeding two checks, the
+ // promotion's has-ever-sent scan, and the kind-conflict flag that replaced a
+ // third throw. Statements (96.45) and lines (98.23) also cleared their pins,
+ // and are deliberately NOT raised: this chapter changed the branch structure,
+ // and moving three ratchets on one chapter's evidence tightens two of them
+ // against a measurement that was never their subject.
+ branches: 92,
functions: 100,
lines: 97,
statements: 95,
},
// THE DEDUPLICATION CHAPTER RAISED THIS, 93 -> 95. The chapter added two pure functions
// to this file — the live-path suppression predicate and the scoping thatfiles changed in relay-platform 31
of those, fenced by this chapter 31
changed and fenced by nobody 0
integration lane 350 tests in 23 suites, 5 of 5 runs green
mean 139.19 s, stdev 0.07, budget 240 s
unit and coverage 45 files, 524 tests, exit 0
repository.ts branches 92.66% (pin raised 90 -> 92), functions 100%Five runs, not twenty-six, and the difference is worth stating rather than hiding. Five is enough to say the lane is not flaky today and nowhere near enough to catch a flake that fires one run in twenty. What five runs did establish is a variance small enough to be surprising: 139.10 to 139.30 seconds, a standard deviation of 0.07 s, a coefficient of variation of 0.05%. A lane that repeats to a tenth of a second is a lane doing the same work each time.
That number is not comparable to any other chapter's, and the reason is not noise. This lane runs 23 suites; the webhook, quota, connection-metering and mail suites are not here yet, and neither is the sealed outsider package. A faster lane is not a faster platform — it is a smaller instrument, and every comparison across these chapters has to say which of the two it means.
The one figure that IS comparable is the branch ratchet, because it is a ratio measured on one
file. repository.ts read 92.66% twice, twenty minutes apart, identical to the uncovered line
numbers — so the pin moved to 92 with 0.66 of headroom. Two readings rather than one, because
this project has watched session.ts report 87.80% and 85.36% on unchanged code, and a ratchet
pinned to a single lucky reading is one that teaches its next reader to lower ratchets.