SendOff — a file lands in a real directory in the recipient's own tree
Implements docs/sendoff.md's v2: a folder convention, one endpoint and a push. No staging area, no transfer table, no ack, nothing to expire. Every account already has a home, a file browser over it and a Files app on the phone, so a delivered file is an ordinary file from the moment it arrives. `Documents/SendOff/<machine>/` for something you sent yourself, and `Documents/SendOff/<sender>/` for another account's files. The home is the one that account actually browses — the owner's real login home, a member's managed home — because the two are different directories and writing to the wrong one makes a delivery invisible rather than wrong. CONSENT IS THE RECIPIENT'S, held as a row per pair. Not a role grant: a grant answers "may this role send files", and the person who should decide whether somebody may write into your home is you. Absence denies, and a sender who was not allowed does not see the recipient in their picker at all — the endpoint answers 404 for "no such account" and for "did not allow you" alike, so it cannot be used to enumerate accounts. The owner bypasses this as a SENDER only; receiving still needs their say-so. The sender's folder name comes from the token and never from the payload, so it cannot lie about who put a file there. Names are sanitised to one path segment and filenames to their basename — verified: `../../../../etc` becomes a literal folder, and a filename of `../../../../tmp/pwned.txt` writes `pwned.txt` inside the target. Collisions suffix ` (1)`; nothing is ever overwritten, because the sender cannot see the directory and would have no way to know. The push carries the folder name and a file count, and the catalogue renders "3 file(s) from andre" on the device. That relaxes "content does not travel" by a folder name and an integer — the owner's call, on the grounds that the file never leaves the tailnet and a notification nobody can act on is not worth sending. A filename would be content; whose folder it is, is addressing. `kind: 'app'` rather than `confined` even though it writes into a home: the path is one fixed shape chosen by the server from the caller's token, so there is nothing to traverse and nothing to read back. Requiring a Linux user would mean an account without one could not be sent a file, which is most of the point. Backend and verified end to end. The web send form is next. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,7 @@ export * from './ai-provider-usage';
|
||||
export * from './anthropic-accounts';
|
||||
export * from './theme-favorites';
|
||||
export { forgetChatSessions, sessionKeyForTranscriptId } from './chat-session-cleanup';
|
||||
export * from './sendoff';
|
||||
export * from './command-hq';
|
||||
export * from './dashboards';
|
||||
export * from './integrations';
|
||||
|
||||
@@ -37,6 +37,7 @@ export * from './ai-provider-usage/schema'; // ai_provider_usage — last usage
|
||||
export * from './anthropic-accounts/schema'; // anthropic_accounts — switchable OAuth credentials
|
||||
export * from './theme-favorites/schema'; // theme_favorites — starred themes, per account
|
||||
export * from './command-hq/schema'; // command_hq_categories, command_hq_sessions, command_hq_tags, command_hq_session_tags
|
||||
export * from './sendoff/schema'; // sendoff_devices, sendoff_senders
|
||||
export * from './agent-panels/schema'; // agent_panels
|
||||
|
||||
// Core because the tailnet is the perimeter — a security model resting on it cannot treat administering
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export { sendoffDevices, sendoffSenders } from './schema';
|
||||
export {
|
||||
upsertSendoffDevice,
|
||||
listSendoffDevices,
|
||||
renameSendoffDevice,
|
||||
deleteSendoffDevice,
|
||||
listAllowedSenders,
|
||||
listMyRecipients,
|
||||
listOtherAccounts,
|
||||
allowSender,
|
||||
revokeSender,
|
||||
maySendTo,
|
||||
accountsByIds,
|
||||
type SendoffDevice,
|
||||
type SendoffPerson,
|
||||
} from './queries';
|
||||
@@ -0,0 +1,122 @@
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { users } from '../auth/schema';
|
||||
import { sendoffDevices, sendoffSenders } from './schema';
|
||||
|
||||
export type SendoffDevice = { id: string; name: string; platform: string | null; lastSeenAt: Date };
|
||||
|
||||
/**
|
||||
* Register or refresh a device. Called on every app start, so it must be idempotent on the client's own id.
|
||||
*
|
||||
* `lastSeenAt` is refreshed on conflict and the name is overwritten, because the client is the authority on
|
||||
* what it calls itself — renaming on the device should show up in the picker without a second endpoint.
|
||||
*/
|
||||
export async function upsertSendoffDevice(params: {
|
||||
id: string;
|
||||
userId: number;
|
||||
name: string;
|
||||
platform?: string | null;
|
||||
}): Promise<void> {
|
||||
await db
|
||||
.insert(sendoffDevices)
|
||||
.values({
|
||||
id: params.id,
|
||||
userId: params.userId,
|
||||
name: params.name,
|
||||
platform: params.platform ?? null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: sendoffDevices.id,
|
||||
set: { name: params.name, platform: params.platform ?? null, lastSeenAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
export async function listSendoffDevices(userId: number): Promise<SendoffDevice[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: sendoffDevices.id,
|
||||
name: sendoffDevices.name,
|
||||
platform: sendoffDevices.platform,
|
||||
lastSeenAt: sendoffDevices.lastSeenAt,
|
||||
})
|
||||
.from(sendoffDevices)
|
||||
.where(eq(sendoffDevices.userId, userId));
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Scoped to the owner of the row: a device id is a client-chosen uuid and must not be a way to reach another account's. */
|
||||
export async function renameSendoffDevice(userId: number, id: string, name: string): Promise<boolean> {
|
||||
const rows = await db
|
||||
.update(sendoffDevices)
|
||||
.set({ name })
|
||||
.where(and(eq(sendoffDevices.id, id), eq(sendoffDevices.userId, userId)))
|
||||
.returning({ id: sendoffDevices.id });
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
export async function deleteSendoffDevice(userId: number, id: string): Promise<boolean> {
|
||||
const rows = await db
|
||||
.delete(sendoffDevices)
|
||||
.where(and(eq(sendoffDevices.id, id), eq(sendoffDevices.userId, userId)))
|
||||
.returning({ id: sendoffDevices.id });
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
export type SendoffPerson = { id: number; email: string; name: string | null };
|
||||
|
||||
/** The accounts I have allowed to send to me. */
|
||||
export async function listAllowedSenders(recipientUserId: number): Promise<SendoffPerson[]> {
|
||||
const rows = await db
|
||||
.select({ id: users.id, email: users.email, name: users.name })
|
||||
.from(sendoffSenders)
|
||||
.innerJoin(users, eq(users.id, sendoffSenders.senderUserId))
|
||||
.where(eq(sendoffSenders.recipientUserId, recipientUserId));
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* The accounts I may send to — the inverse question, and the one the picker asks.
|
||||
*
|
||||
* Absence denies, so this returns only recipients who have named me. A sender never learns that an account
|
||||
* exists and refused them; it simply is not in the list.
|
||||
*/
|
||||
export async function listMyRecipients(senderUserId: number): Promise<SendoffPerson[]> {
|
||||
const rows = await db
|
||||
.select({ id: users.id, email: users.email, name: users.name })
|
||||
.from(sendoffSenders)
|
||||
.innerJoin(users, eq(users.id, sendoffSenders.recipientUserId))
|
||||
.where(eq(sendoffSenders.senderUserId, senderUserId));
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Every account except me — what the owner's picker shows, since the owner may send to anyone. */
|
||||
export async function listOtherAccounts(exceptUserId: number): Promise<SendoffPerson[]> {
|
||||
const rows = await db.select({ id: users.id, email: users.email, name: users.name }).from(users);
|
||||
return rows.filter((r) => r.id !== exceptUserId);
|
||||
}
|
||||
|
||||
export async function allowSender(recipientUserId: number, senderUserId: number): Promise<void> {
|
||||
await db.insert(sendoffSenders).values({ recipientUserId, senderUserId }).onConflictDoNothing();
|
||||
}
|
||||
|
||||
export async function revokeSender(recipientUserId: number, senderUserId: number): Promise<void> {
|
||||
await db
|
||||
.delete(sendoffSenders)
|
||||
.where(and(eq(sendoffSenders.recipientUserId, recipientUserId), eq(sendoffSenders.senderUserId, senderUserId)));
|
||||
}
|
||||
|
||||
/** May this sender write into this recipient's tree? The owner check is the CALLER's job — see the schema. */
|
||||
export async function maySendTo(senderUserId: number, recipientUserId: number): Promise<boolean> {
|
||||
const rows = await db
|
||||
.select({ id: sendoffSenders.id })
|
||||
.from(sendoffSenders)
|
||||
.where(and(eq(sendoffSenders.recipientUserId, recipientUserId), eq(sendoffSenders.senderUserId, senderUserId)))
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/** Resolve accounts by id, for naming a recipient in a response without a second round trip. */
|
||||
export async function accountsByIds(ids: number[]): Promise<SendoffPerson[]> {
|
||||
if (!ids.length) return [];
|
||||
return db.select({ id: users.id, email: users.email, name: users.name }).from(users).where(inArray(users.id, ids));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { pgTable, serial, integer, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { users } from '../auth/schema';
|
||||
|
||||
/**
|
||||
* A named machine, so "send it to my laptop" has something to pick from.
|
||||
*
|
||||
* Separate from `push_devices` deliberately, and the two are not the same population. A push device is a
|
||||
* token: iOS or Android only, no human name, and it exists to be pushed AT. This is a name a person chose,
|
||||
* for a machine that may never receive a push at all — a desktop, a browser, a box with no app on it. The
|
||||
* name's only job is to become a folder.
|
||||
*
|
||||
* The id is the client's own uuid rather than a serial, because registration happens on every app start and
|
||||
* has to be idempotent without the client remembering a number we gave it.
|
||||
*/
|
||||
export const sendoffDevices = pgTable('sendoff_devices', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** What the owner calls it. Becomes a directory name, so it is sanitised before it touches the disk. */
|
||||
name: text('name').notNull(),
|
||||
platform: text('platform'),
|
||||
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Who may send files to whom — held by the RECIPIENT, one row per sender they have allowed.
|
||||
*
|
||||
* This is the whole cross-user permission model and it is deliberately not a role grant. A grant answers
|
||||
* "may this role send files", which is the wrong question: the person who should decide whether Fedra can
|
||||
* put a file in your home directory is you, not whoever administers roles. So consent is data, it is
|
||||
* per-pair, and absence denies — a sender who has not been allowed does not see the recipient as a target
|
||||
* at all, rather than seeing one and being refused.
|
||||
*
|
||||
* The owner is exempt as a SENDER and only as a sender: they bypass every other check on this platform and
|
||||
* there is no version of this where they cannot reach a home directory they already own. Receiving still
|
||||
* needs their say-so, so nobody lands files in the owner's tree uninvited.
|
||||
*/
|
||||
export const sendoffSenders = pgTable(
|
||||
'sendoff_senders',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
/** The account granting permission — the one whose tree gets written to. */
|
||||
recipientUserId: integer('recipient_user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** The account allowed to send to them. */
|
||||
senderUserId: integer('sender_user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
// uniqueIndex, never unique().on() — drizzle-kit re-creates named composite constraints on every push.
|
||||
(table) => [uniqueIndex('uq_sendoff_senders').on(table.recipientUserId, table.senderUserId)],
|
||||
);
|
||||
Reference in New Issue
Block a user