parent
c5dd0e337c
commit
6e29d73572
@ -0,0 +1,312 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { createServer } from 'node:http';
|
||||
|
||||
const HOST = process.env.CONN_CHAT_HOST ?? '127.0.0.1';
|
||||
const PORT = Number(process.env.CONN_CHAT_PORT ?? 8787);
|
||||
const WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||
const HEADER_UPGRADE = 'upgrade';
|
||||
const HEADER_WEBSOCKET_KEY = 'sec-websocket-key';
|
||||
const UPGRADE_WEBSOCKET = 'websocket';
|
||||
const OPCODE_TEXT = 0x1;
|
||||
const OPCODE_CLOSE = 0x8;
|
||||
const OPCODE_PING = 0x9;
|
||||
const OPCODE_PONG = 0xa;
|
||||
const CLOSE_CODE_NORMAL = 1000;
|
||||
const CLOSE_CODE_PROTOCOL_ERROR = 1002;
|
||||
const FRAME_TYPE_AUTH = 'conn.auth';
|
||||
const FRAME_TYPE_JOIN = 'conn.join';
|
||||
const FRAME_TYPE_LEAVE = 'conn.leave';
|
||||
const FRAME_TYPE_PING = 'conn.ping';
|
||||
const FRAME_TYPE_PONG = 'conn.pong';
|
||||
const SERVER_REPLY_EVENT = 'server.reply';
|
||||
const CHAT_EVENT_MESSAGE = 'chat.message';
|
||||
const CHAT_EVENT_SYSTEM = 'chat.system';
|
||||
const CHAT_EVENT_TYPING = 'chat.typing';
|
||||
const CHAT_EVENT_WHOAMI = 'chat.whoami';
|
||||
const DEMO_EVENT_DISCONNECT = 'demo.disconnect';
|
||||
const BOT_NAME = 'Ava Bot';
|
||||
const DEFAULT_ROOM = 'room:lobby';
|
||||
const DEFAULT_USER = 'Anon';
|
||||
const BOT_REPLY_DELAY_MS = 450;
|
||||
const FORCED_CLOSE_DELAY_MS = 80;
|
||||
|
||||
const clients = new Map();
|
||||
|
||||
function makeAcceptKey(key) {
|
||||
return createHash('sha1').update(`${key}${WEBSOCKET_GUID}`).digest('base64');
|
||||
}
|
||||
|
||||
function encodeFrame(opcode, payload = Buffer.alloc(0)) {
|
||||
const data = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload));
|
||||
const len = data.length;
|
||||
let header;
|
||||
if (len < 126) {
|
||||
header = Buffer.alloc(2);
|
||||
header[1] = len;
|
||||
} else if (len <= 0xffff) {
|
||||
header = Buffer.alloc(4);
|
||||
header[1] = 126;
|
||||
header.writeUInt16BE(len, 2);
|
||||
} else {
|
||||
header = Buffer.alloc(10);
|
||||
header[1] = 127;
|
||||
header.writeBigUInt64BE(BigInt(len), 2);
|
||||
}
|
||||
header[0] = 0x80 | opcode;
|
||||
return Buffer.concat([header, data]);
|
||||
}
|
||||
|
||||
function sendText(socket, text) {
|
||||
if (socket.destroyed) return;
|
||||
socket.write(encodeFrame(OPCODE_TEXT, text));
|
||||
}
|
||||
|
||||
function sendJson(socket, frame) {
|
||||
sendText(socket, JSON.stringify(frame));
|
||||
}
|
||||
|
||||
function closeSocket(socket, code = CLOSE_CODE_NORMAL, reason = '') {
|
||||
if (socket.destroyed) return;
|
||||
const body = Buffer.alloc(2 + Buffer.byteLength(reason));
|
||||
body.writeUInt16BE(code, 0);
|
||||
body.write(reason, 2);
|
||||
socket.write(encodeFrame(OPCODE_CLOSE, body));
|
||||
socket.end();
|
||||
}
|
||||
|
||||
function createMessage(user, text, kind) {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
user,
|
||||
text,
|
||||
at: Date.now(),
|
||||
kind
|
||||
};
|
||||
}
|
||||
|
||||
function reply(socket, frame, payload, error) {
|
||||
if (typeof frame.id !== 'string') return;
|
||||
sendJson(socket, {
|
||||
type: SERVER_REPLY_EVENT,
|
||||
replyTo: frame.id,
|
||||
payload,
|
||||
error
|
||||
});
|
||||
}
|
||||
|
||||
function broadcast(room, frame) {
|
||||
for (const [socket, client] of clients) {
|
||||
if (client.room !== room) continue;
|
||||
sendJson(socket, frame);
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastSystem(room, text) {
|
||||
broadcast(room, {
|
||||
topic: room,
|
||||
type: CHAT_EVENT_SYSTEM,
|
||||
payload: createMessage('Sistema', text, 'system')
|
||||
});
|
||||
}
|
||||
|
||||
function parseFrames(client) {
|
||||
const out = [];
|
||||
let offset = 0;
|
||||
const buffer = client.buffer;
|
||||
|
||||
while (offset + 2 <= buffer.length) {
|
||||
const first = buffer[offset];
|
||||
const second = buffer[offset + 1];
|
||||
const opcode = first & 0x0f;
|
||||
const masked = (second & 0x80) !== 0;
|
||||
let len = second & 0x7f;
|
||||
let cursor = offset + 2;
|
||||
|
||||
if (len === 126) {
|
||||
if (cursor + 2 > buffer.length) break;
|
||||
len = buffer.readUInt16BE(cursor);
|
||||
cursor += 2;
|
||||
} else if (len === 127) {
|
||||
if (cursor + 8 > buffer.length) break;
|
||||
const longLen = buffer.readBigUInt64BE(cursor);
|
||||
if (longLen > BigInt(Number.MAX_SAFE_INTEGER)) {
|
||||
throw new Error('websocket frame too large');
|
||||
}
|
||||
len = Number(longLen);
|
||||
cursor += 8;
|
||||
}
|
||||
|
||||
const maskOffset = cursor;
|
||||
if (masked) cursor += 4;
|
||||
if (cursor + len > buffer.length) break;
|
||||
|
||||
const payload = Buffer.from(buffer.subarray(cursor, cursor + len));
|
||||
if (masked) {
|
||||
const mask = buffer.subarray(maskOffset, maskOffset + 4);
|
||||
for (let i = 0; i < payload.length; i += 1) payload[i] ^= mask[i % 4];
|
||||
}
|
||||
|
||||
out.push({ opcode, payload });
|
||||
offset = cursor + len;
|
||||
}
|
||||
|
||||
client.buffer = buffer.subarray(offset);
|
||||
return out;
|
||||
}
|
||||
|
||||
function handleFrame(socket, rawFrame) {
|
||||
const client = clients.get(socket);
|
||||
if (client === undefined) return;
|
||||
|
||||
if (rawFrame.opcode === OPCODE_CLOSE) {
|
||||
closeSocket(socket);
|
||||
return;
|
||||
}
|
||||
if (rawFrame.opcode === OPCODE_PING) {
|
||||
socket.write(encodeFrame(OPCODE_PONG, rawFrame.payload));
|
||||
return;
|
||||
}
|
||||
if (rawFrame.opcode !== OPCODE_TEXT) return;
|
||||
|
||||
let frame;
|
||||
try {
|
||||
frame = JSON.parse(rawFrame.payload.toString('utf8'));
|
||||
} catch {
|
||||
closeSocket(socket, CLOSE_CODE_PROTOCOL_ERROR, 'invalid json');
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === FRAME_TYPE_AUTH) {
|
||||
client.authed = true;
|
||||
client.user = frame.payload?.user ?? client.user;
|
||||
reply(socket, frame, { accepted: true, server: 'conn-chat-server' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === FRAME_TYPE_JOIN) {
|
||||
const room = typeof frame.topic === 'string' ? frame.topic : DEFAULT_ROOM;
|
||||
client.room = room;
|
||||
client.user = frame.payload?.user ?? client.user;
|
||||
broadcastSystem(room, `${client.user} ha entrado en ${room}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === FRAME_TYPE_LEAVE) {
|
||||
const room = client.room;
|
||||
client.room = null;
|
||||
if (room !== null) broadcastSystem(room, `${client.user} ha salido de ${room}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === FRAME_TYPE_PING) {
|
||||
sendJson(socket, { type: FRAME_TYPE_PONG, payload: { at: Date.now() } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === CHAT_EVENT_WHOAMI) {
|
||||
reply(socket, frame, {
|
||||
user: client.user,
|
||||
room: client.room,
|
||||
connection: client.id,
|
||||
generation: client.generation
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === DEMO_EVENT_DISCONNECT) {
|
||||
setTimeout(() => {
|
||||
closeSocket(socket, CLOSE_CODE_NORMAL, 'demo disconnect');
|
||||
}, FORCED_CLOSE_DELAY_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === CHAT_EVENT_MESSAGE) {
|
||||
const room = typeof frame.topic === 'string' ? frame.topic : client.room;
|
||||
if (room === null) return;
|
||||
broadcast(room, frame);
|
||||
broadcast(room, {
|
||||
topic: room,
|
||||
type: CHAT_EVENT_TYPING,
|
||||
payload: { user: BOT_NAME, active: true }
|
||||
});
|
||||
setTimeout(() => {
|
||||
broadcast(room, {
|
||||
topic: room,
|
||||
type: CHAT_EVENT_TYPING,
|
||||
payload: { user: BOT_NAME, active: false }
|
||||
});
|
||||
broadcast(room, {
|
||||
topic: room,
|
||||
type: CHAT_EVENT_MESSAGE,
|
||||
payload: createMessage(
|
||||
BOT_NAME,
|
||||
`Recibido por ${client.user}: "${frame.payload?.text ?? ''}"`,
|
||||
'bot'
|
||||
)
|
||||
});
|
||||
}, BOT_REPLY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
const server = createServer((_, response) => {
|
||||
response.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
response.end('conn chat websocket server\n');
|
||||
});
|
||||
|
||||
server.on('upgrade', (request, socket) => {
|
||||
const upgrade = request.headers[HEADER_UPGRADE];
|
||||
const key = request.headers[HEADER_WEBSOCKET_KEY];
|
||||
if (upgrade !== UPGRADE_WEBSOCKET || typeof key !== 'string') {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
socket.write(
|
||||
[
|
||||
'HTTP/1.1 101 Switching Protocols',
|
||||
'Upgrade: websocket',
|
||||
'Connection: Upgrade',
|
||||
`Sec-WebSocket-Accept: ${makeAcceptKey(key)}`,
|
||||
'',
|
||||
''
|
||||
].join('\r\n')
|
||||
);
|
||||
|
||||
const client = {
|
||||
id: randomUUID(),
|
||||
user: DEFAULT_USER,
|
||||
room: null,
|
||||
authed: false,
|
||||
generation: Date.now(),
|
||||
buffer: Buffer.alloc(0)
|
||||
};
|
||||
clients.set(socket, client);
|
||||
console.log(`[conn-chat] connected ${client.id}`);
|
||||
|
||||
socket.on('data', (chunk) => {
|
||||
const current = clients.get(socket);
|
||||
if (current === undefined) return;
|
||||
current.buffer = Buffer.concat([current.buffer, chunk]);
|
||||
try {
|
||||
for (const frame of parseFrames(current)) handleFrame(socket, frame);
|
||||
} catch (error) {
|
||||
console.error('[conn-chat] protocol error', error);
|
||||
closeSocket(socket, CLOSE_CODE_PROTOCOL_ERROR, 'protocol error');
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
const current = clients.get(socket);
|
||||
clients.delete(socket);
|
||||
if (current?.room) broadcastSystem(current.room, `${current.user} se ha desconectado.`);
|
||||
console.log(`[conn-chat] disconnected ${current?.id ?? 'unknown'}`);
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('[conn-chat] socket error', error);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`[conn-chat] ws://${HOST}:${PORT}`);
|
||||
});
|
||||
@ -1 +1,10 @@
|
||||
export const LOGGER_CATEGORY = 'app';
|
||||
|
||||
export const APP_STORAGE_ERROR_MESSAGE = (op: string, fullKey: string): string =>
|
||||
`${op} on "${fullKey}"`;
|
||||
|
||||
export const APP_ERROR_ALREADY_CREATED_SESSION =
|
||||
'[aapp] App.createActiveSession() called twice — only one session per App.';
|
||||
|
||||
export const APP_ERROR_ALREADY_CREATED_PERMISSIONS =
|
||||
'[aapp] App.createActivePermissions() called twice — only one permissions client per App.';
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
# `arts/conn`
|
||||
|
||||
`conn` manages runtime realtime connections without coupling the app to WebSocket as a concept. The root artifact is a registry:
|
||||
|
||||
```ts
|
||||
import { createEngineConnections, createWebSocketTransport } from '$conn';
|
||||
|
||||
const Connections = createEngineConnections();
|
||||
|
||||
const Main = Connections.createConnection('main', {
|
||||
transport: createWebSocketTransport({ url: () => '/realtime' }),
|
||||
heartbeat: false
|
||||
});
|
||||
|
||||
await Main.connect();
|
||||
```
|
||||
|
||||
## Naming
|
||||
|
||||
- Root engine: `createEngineConnections()` / `EngineConnections`.
|
||||
- Reactive root: `createActiveConnections()` / `ActiveConnections`.
|
||||
- Individual unit: `Connection`.
|
||||
- Logical topic: `ConnectionChannel`.
|
||||
|
||||
There is intentionally no `EngineConnection` or `ActiveConnection`; `Engine*` and `Active*` are reserved for artifact roots.
|
||||
|
||||
## App Integration
|
||||
|
||||
`aapp` exposes a factory instead of a permanent property:
|
||||
|
||||
```ts
|
||||
const Connections = App.createActiveConnections<AppConnections>();
|
||||
```
|
||||
|
||||
App injects `Logger`, `Timers` and a structural session event bridge. A connection reacts to session events only when it opts in:
|
||||
|
||||
```ts
|
||||
Connections.createConnection('main', {
|
||||
transport: createWebSocketTransport({ url: '/realtime' }),
|
||||
auth: () => ({ token: App.Sess?.current?.credential }),
|
||||
session: {
|
||||
enabled: true,
|
||||
reauthOnRefresh: true,
|
||||
disconnectOnExpire: true
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Channels
|
||||
|
||||
Channels are cached by name and route frames by `topic`.
|
||||
|
||||
```ts
|
||||
const Orders = Main.channel<{ 'order.updated': { id: string } }>('orders');
|
||||
|
||||
Orders.on('order.updated', (payload) => {
|
||||
console.log(payload.id);
|
||||
});
|
||||
|
||||
await Orders.join({ tenant: 'acme' });
|
||||
```
|
||||
|
||||
## Request / Reply
|
||||
|
||||
`request()` creates a frame id, sends `ack: true`, and resolves when an incoming frame carries `replyTo` with that id.
|
||||
|
||||
```ts
|
||||
const result = await Main.request<{ id: string }, { ok: boolean }>('order.sync', {
|
||||
id: 'o1'
|
||||
});
|
||||
```
|
||||
|
||||
Runtime failures return tagged results. Programmer errors such as duplicated names or invalid connection names throw typed `Conn*` errors.
|
||||
@ -0,0 +1,127 @@
|
||||
import { createEngineConnections } from './engine-connections.ts';
|
||||
import {
|
||||
CONNECTION_STATE_CLOSED,
|
||||
CONNECTION_STATE_CONNECTING,
|
||||
CONNECTION_STATE_FAILED,
|
||||
CONNECTION_STATE_OPEN,
|
||||
CONNECTION_STATE_RECONNECTING
|
||||
} from './consts.ts';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import type {
|
||||
ActiveConnections,
|
||||
ActiveConnectionsOptions,
|
||||
Connection,
|
||||
ConnectionChannelMap,
|
||||
ConnectionMap,
|
||||
ConnectionOptions,
|
||||
ConnectionState
|
||||
} from './types.ts';
|
||||
|
||||
export function createActiveConnections<TConnections extends ConnectionMap = ConnectionMap>(
|
||||
options: ActiveConnectionsOptions = {}
|
||||
): ActiveConnections<TConnections> {
|
||||
const engine = createEngineConnections<TConnections>(options);
|
||||
const detachers = new SvelteMap<string, () => void>();
|
||||
let activeNames = $state<readonly string[]>(engine.names());
|
||||
let states = $state<Readonly<Record<string, ConnectionState>>>({});
|
||||
|
||||
function refreshNames(): void {
|
||||
activeNames = engine.names();
|
||||
}
|
||||
|
||||
function setConnectionState(name: string, state: ConnectionState): void {
|
||||
states = { ...states, [name]: state };
|
||||
}
|
||||
|
||||
function namesBy(state: ConnectionState): readonly string[] {
|
||||
return activeNames.filter((name) => states[name] === state);
|
||||
}
|
||||
|
||||
function observe(name: string, connection: Connection): void {
|
||||
detachers.get(name)?.();
|
||||
setConnectionState(name, connection.state);
|
||||
detachers.set(
|
||||
name,
|
||||
connection.onState((change) => {
|
||||
setConnectionState(change.connection, change.to);
|
||||
})
|
||||
);
|
||||
refreshNames();
|
||||
}
|
||||
|
||||
const active: ActiveConnections<TConnections> = {
|
||||
get size() {
|
||||
return activeNames.length;
|
||||
},
|
||||
get activeNames() {
|
||||
return activeNames;
|
||||
},
|
||||
get states() {
|
||||
return states;
|
||||
},
|
||||
get connectedNames() {
|
||||
return namesBy(CONNECTION_STATE_OPEN);
|
||||
},
|
||||
get connectingNames() {
|
||||
return namesBy(CONNECTION_STATE_CONNECTING);
|
||||
},
|
||||
get reconnectingNames() {
|
||||
return namesBy(CONNECTION_STATE_RECONNECTING);
|
||||
},
|
||||
get failedNames() {
|
||||
return namesBy(CONNECTION_STATE_FAILED);
|
||||
},
|
||||
get closedNames() {
|
||||
return namesBy(CONNECTION_STATE_CLOSED);
|
||||
},
|
||||
get allConnected() {
|
||||
return (
|
||||
activeNames.length > 0 &&
|
||||
activeNames.every((name) => states[name] === CONNECTION_STATE_OPEN)
|
||||
);
|
||||
},
|
||||
get anyConnected() {
|
||||
return activeNames.some((name) => states[name] === CONNECTION_STATE_OPEN);
|
||||
},
|
||||
get anyConnecting() {
|
||||
return activeNames.some((name) => states[name] === CONNECTION_STATE_CONNECTING);
|
||||
},
|
||||
get anyReconnecting() {
|
||||
return activeNames.some((name) => states[name] === CONNECTION_STATE_RECONNECTING);
|
||||
},
|
||||
get anyFailed() {
|
||||
return activeNames.some((name) => states[name] === CONNECTION_STATE_FAILED);
|
||||
},
|
||||
createConnection<TChannels extends ConnectionChannelMap = ConnectionChannelMap>(
|
||||
name: string,
|
||||
connectionOptions: ConnectionOptions<TChannels>
|
||||
): Connection<TChannels> {
|
||||
const connection = engine.createConnection<TChannels>(name, connectionOptions);
|
||||
observe(name, connection as Connection);
|
||||
return connection;
|
||||
},
|
||||
connection<TChannels extends ConnectionChannelMap = ConnectionChannelMap>(
|
||||
name: string
|
||||
): Connection<TChannels> {
|
||||
return engine.connection<TChannels>(name);
|
||||
},
|
||||
has: (name) => engine.has(name),
|
||||
names: () => engine.names(),
|
||||
openConnection: (name) => engine.openConnection(name),
|
||||
closeConnection: (name, reason) => engine.closeConnection(name, reason),
|
||||
reconnectConnection: (name, reason) => engine.reconnectConnection(name, reason),
|
||||
openAll: () => engine.openAll(),
|
||||
closeAll: (reason) => engine.closeAll(reason),
|
||||
reconnectAll: (reason) => engine.reconnectAll(reason),
|
||||
close: (name, reason) => engine.close(name, reason),
|
||||
dispose() {
|
||||
for (const detach of detachers.values()) detach();
|
||||
detachers.clear();
|
||||
activeNames = [];
|
||||
states = {};
|
||||
engine.dispose();
|
||||
}
|
||||
};
|
||||
|
||||
return active;
|
||||
}
|
||||
@ -0,0 +1,177 @@
|
||||
import {
|
||||
CONNECTION_CHANNEL_JOIN_REASON_CLOSED,
|
||||
CONNECTION_CHANNEL_JOIN_REASON_REJECTED,
|
||||
CONNECTION_CHANNEL_JOIN_REASON_TRANSPORT_ERROR,
|
||||
CONNECTION_CHANNEL_STATE_FAILED,
|
||||
CONNECTION_CHANNEL_STATE_IDLE,
|
||||
CONNECTION_CHANNEL_STATE_JOINED,
|
||||
CONNECTION_CHANNEL_STATE_JOINING,
|
||||
CONNECTION_CHANNEL_STATE_LEAVING,
|
||||
CONNECTION_CHANNEL_STATE_LEFT,
|
||||
CONNECTION_FRAME_TYPE_JOIN,
|
||||
CONNECTION_FRAME_TYPE_LEAVE,
|
||||
CONNECTION_SEND_REASON_CLOSED
|
||||
} from './consts.ts';
|
||||
import type {
|
||||
Connection,
|
||||
ConnectionAckResult,
|
||||
ConnectionChannel,
|
||||
ConnectionChannelJoinResult,
|
||||
ConnectionChannelOptions,
|
||||
ConnectionChannelState,
|
||||
ConnectionEventMap,
|
||||
ConnectionFrame,
|
||||
ConnectionMessageMeta,
|
||||
ConnectionRequestOptions,
|
||||
ConnectionSendOptions,
|
||||
ConnectionSendResult
|
||||
} from './types.ts';
|
||||
|
||||
export interface InternalConnectionChannel<
|
||||
TEvents extends ConnectionEventMap = ConnectionEventMap
|
||||
> extends ConnectionChannel<TEvents> {
|
||||
readonly shouldRejoin: boolean;
|
||||
receive(frame: ConnectionFrame, meta: ConnectionMessageMeta): void;
|
||||
rejoin(): Promise<ConnectionChannelJoinResult>;
|
||||
}
|
||||
|
||||
export interface ConnectionChannelRuntime<TEvents extends ConnectionEventMap = ConnectionEventMap> {
|
||||
readonly connection: Connection<Record<string, TEvents>>;
|
||||
readonly reportListenerError?: (event: string, error: unknown) => void;
|
||||
}
|
||||
|
||||
function mapSendToJoinResult(result: ConnectionSendResult): ConnectionChannelJoinResult {
|
||||
if (result.ok) return { ok: true };
|
||||
if (result.reason === CONNECTION_SEND_REASON_CLOSED) {
|
||||
return { ok: false, reason: CONNECTION_CHANNEL_JOIN_REASON_CLOSED, error: result.error };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
result.error === undefined
|
||||
? CONNECTION_CHANNEL_JOIN_REASON_REJECTED
|
||||
: CONNECTION_CHANNEL_JOIN_REASON_TRANSPORT_ERROR,
|
||||
error: result.error
|
||||
};
|
||||
}
|
||||
|
||||
export function createConnectionChannel<TEvents extends ConnectionEventMap = ConnectionEventMap>(
|
||||
name: string,
|
||||
options: ConnectionChannelOptions = {},
|
||||
runtime: ConnectionChannelRuntime<TEvents>
|
||||
): InternalConnectionChannel<TEvents> {
|
||||
let state: ConnectionChannelState = CONNECTION_CHANNEL_STATE_IDLE;
|
||||
let desiredJoined = options.autoJoin === true;
|
||||
let disposed = false;
|
||||
|
||||
const anyListeners = new Set<(frame: ConnectionFrame, meta: ConnectionMessageMeta) => void>();
|
||||
const typeListeners = new Map<
|
||||
string,
|
||||
Set<(payload: unknown, meta: ConnectionMessageMeta) => void>
|
||||
>();
|
||||
|
||||
function setState(next: ConnectionChannelState): void {
|
||||
state = next;
|
||||
}
|
||||
|
||||
function emitToListeners(frame: ConnectionFrame, meta: ConnectionMessageMeta): void {
|
||||
for (const listener of [...anyListeners]) {
|
||||
try {
|
||||
listener(frame, meta);
|
||||
} catch (error) {
|
||||
runtime.reportListenerError?.(frame.type, error);
|
||||
}
|
||||
}
|
||||
const listeners = typeListeners.get(frame.type);
|
||||
if (listeners === undefined) return;
|
||||
for (const listener of [...listeners]) {
|
||||
try {
|
||||
listener(frame.payload, meta);
|
||||
} catch (error) {
|
||||
runtime.reportListenerError?.(frame.type, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveJoinPayload(explicitParams: unknown): Promise<unknown> {
|
||||
if (explicitParams !== undefined) return explicitParams;
|
||||
return options.params?.();
|
||||
}
|
||||
|
||||
const channel: InternalConnectionChannel<TEvents> = {
|
||||
name,
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
get shouldRejoin() {
|
||||
return desiredJoined && options.rejoinOnReconnect !== false;
|
||||
},
|
||||
async join(params) {
|
||||
if (disposed) return { ok: false, reason: CONNECTION_CHANNEL_JOIN_REASON_CLOSED };
|
||||
setState(CONNECTION_CHANNEL_STATE_JOINING);
|
||||
const payload = await resolveJoinPayload(params);
|
||||
const result = await runtime.connection.send(CONNECTION_FRAME_TYPE_JOIN, payload, {
|
||||
topic: name
|
||||
});
|
||||
const mapped = mapSendToJoinResult(result);
|
||||
if (mapped.ok) {
|
||||
desiredJoined = true;
|
||||
setState(CONNECTION_CHANNEL_STATE_JOINED);
|
||||
} else {
|
||||
setState(CONNECTION_CHANNEL_STATE_FAILED);
|
||||
}
|
||||
return mapped;
|
||||
},
|
||||
async leave() {
|
||||
if (disposed) return;
|
||||
setState(CONNECTION_CHANNEL_STATE_LEAVING);
|
||||
desiredJoined = false;
|
||||
await runtime.connection.send(CONNECTION_FRAME_TYPE_LEAVE, undefined, { topic: name });
|
||||
setState(CONNECTION_CHANNEL_STATE_LEFT);
|
||||
},
|
||||
send(type, payload, sendOptions?: ConnectionSendOptions): Promise<ConnectionSendResult> {
|
||||
return runtime.connection.send(type, payload, { ...sendOptions, topic: name });
|
||||
},
|
||||
request<K extends keyof TEvents & string, TResult = unknown>(
|
||||
type: K,
|
||||
payload: TEvents[K],
|
||||
requestOptions?: ConnectionRequestOptions
|
||||
): Promise<ConnectionAckResult<TResult>> {
|
||||
return runtime.connection.request(type, payload, { ...requestOptions, topic: name });
|
||||
},
|
||||
on(type, listener) {
|
||||
let listeners = typeListeners.get(type);
|
||||
if (listeners === undefined) {
|
||||
listeners = new Set();
|
||||
typeListeners.set(type, listeners);
|
||||
}
|
||||
const wrapped = listener as (payload: unknown, meta: ConnectionMessageMeta) => void;
|
||||
listeners.add(wrapped);
|
||||
return () => {
|
||||
listeners?.delete(wrapped);
|
||||
if (listeners?.size === 0) typeListeners.delete(type);
|
||||
};
|
||||
},
|
||||
onAny(listener) {
|
||||
anyListeners.add(listener);
|
||||
return () => {
|
||||
anyListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
receive(frame, meta) {
|
||||
if (disposed) return;
|
||||
emitToListeners(frame, meta);
|
||||
},
|
||||
rejoin() {
|
||||
if (!channel.shouldRejoin) return Promise.resolve({ ok: true });
|
||||
return channel.join();
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
anyListeners.clear();
|
||||
typeListeners.clear();
|
||||
}
|
||||
};
|
||||
|
||||
return channel;
|
||||
}
|
||||
@ -0,0 +1,865 @@
|
||||
import { computeBackoffDelay } from '$libs/timers';
|
||||
import type { TimerScheduler } from '$timr';
|
||||
import { createConnectionChannel, type InternalConnectionChannel } from './channel.ts';
|
||||
import {
|
||||
BROWSER_EVENT_ONLINE,
|
||||
BROWSER_EVENT_VISIBILITY_CHANGE,
|
||||
CONNECTION_ACK_REASON_CLOSED,
|
||||
CONNECTION_ACK_REASON_REJECTED,
|
||||
CONNECTION_ACK_REASON_TIMEOUT,
|
||||
CONNECTION_ACK_REASON_TRANSPORT_ERROR,
|
||||
CONNECTION_AUTH_REASON_CLOSED,
|
||||
CONNECTION_AUTH_REASON_NO_PROVIDER,
|
||||
CONNECTION_AUTH_REASON_REJECTED,
|
||||
CONNECTION_AUTH_REASON_TIMEOUT,
|
||||
CONNECTION_AUTH_REASON_TRANSPORT_ERROR,
|
||||
CONNECTION_BUFFER_POLICY_BUFFER,
|
||||
CONNECTION_BUFFER_POLICY_DROP,
|
||||
CONNECTION_CLOSE_REASON_DISCONNECT,
|
||||
CONNECTION_CLOSE_REASON_DISPOSE,
|
||||
CONNECTION_CLOSE_REASON_AUTH_FAILED,
|
||||
CONNECTION_CLOSE_REASON_HEARTBEAT_TIMEOUT,
|
||||
CONNECTION_CLOSE_REASON_RECONNECT,
|
||||
CONNECTION_CLOSE_REASON_SESSION_EXPIRED,
|
||||
CONNECTION_CONNECT_REASON_AUTH_FAILED,
|
||||
CONNECTION_CONNECT_REASON_DISPOSED,
|
||||
CONNECTION_CONNECT_REASON_TRANSPORT_ERROR,
|
||||
CONNECTION_FRAME_TYPE_AUTH,
|
||||
CONNECTION_FRAME_TYPE_PING,
|
||||
CONNECTION_FRAME_TYPE_PONG,
|
||||
CONNECTION_ID_PREFIX,
|
||||
CONNECTION_ID_SEPARATOR,
|
||||
CONNECTION_SEND_REASON_BUFFER_FULL,
|
||||
CONNECTION_SEND_REASON_CLOSED,
|
||||
CONNECTION_SEND_REASON_INVALID_FRAME,
|
||||
CONNECTION_SEND_REASON_SEND_NOT_SUPPORTED,
|
||||
CONNECTION_SEND_REASON_SERIALIZE_FAILED,
|
||||
CONNECTION_SEND_REASON_TRANSPORT_ERROR,
|
||||
CONNECTION_STATE_CLOSED,
|
||||
CONNECTION_STATE_CLOSING,
|
||||
CONNECTION_STATE_CONNECTING,
|
||||
CONNECTION_STATE_FAILED,
|
||||
CONNECTION_STATE_IDLE,
|
||||
CONNECTION_STATE_OPEN,
|
||||
CONNECTION_STATE_RECONNECTING,
|
||||
CONNECTION_TRANSPORT_STATE_OPEN,
|
||||
DEFAULT_ACK_TIMEOUT_MS,
|
||||
DEFAULT_BUFFER_MAX_BYTES,
|
||||
DEFAULT_BUFFER_MAX_MESSAGES,
|
||||
DEFAULT_HEARTBEAT_ENABLED,
|
||||
DEFAULT_HEARTBEAT_INTERVAL_MS,
|
||||
DEFAULT_HEARTBEAT_TIMEOUT_MS,
|
||||
DEFAULT_RECONNECT_ENABLED,
|
||||
DEFAULT_RECONNECT_FACTOR,
|
||||
DEFAULT_RECONNECT_JITTER_MS,
|
||||
DEFAULT_RECONNECT_MAX_DELAY_MS,
|
||||
DEFAULT_RECONNECT_MIN_DELAY_MS,
|
||||
DEFAULT_RECONNECT_ON_ONLINE,
|
||||
DEFAULT_RECONNECT_ON_VISIBLE,
|
||||
DOCUMENT_VISIBILITY_VISIBLE,
|
||||
LOG_MSG_AUTH_FAILED,
|
||||
LOG_MSG_BROWSER_RECONNECT,
|
||||
LOG_MSG_CONNECT_FAILED,
|
||||
LOG_MSG_FRAME_DECODE_FAILED,
|
||||
LOG_MSG_FRAME_ENCODE_FAILED,
|
||||
LOG_MSG_HEARTBEAT_TIMEOUT,
|
||||
LOG_MSG_REAUTH_FAILED,
|
||||
LOG_MSG_RECONNECT_EXHAUSTED,
|
||||
LOG_MSG_SEND_FAILED,
|
||||
LOG_MSG_SESSION_EXPIRED,
|
||||
LOG_MSG_SESSION_REFRESHED,
|
||||
LOG_MSG_SESSION_REVOKED,
|
||||
LOG_MSG_TRANSPORT_ERROR,
|
||||
SESSION_EVENT_EXPIRED,
|
||||
SESSION_EVENT_REFRESHED,
|
||||
SESSION_EVENT_REVOKED,
|
||||
TIMER_KEY_ACK,
|
||||
TIMER_KEY_HEARTBEAT,
|
||||
TIMER_KEY_HEARTBEAT_TIMEOUT,
|
||||
TIMER_KEY_RECONNECT,
|
||||
loggerScope,
|
||||
listenerThrewMessage,
|
||||
timerKey
|
||||
} from './consts.ts';
|
||||
import { assertConnectionFrame, createFrame } from './serializer.ts';
|
||||
import { jsonConnectionSerializer } from './serializers/json.ts';
|
||||
import type {
|
||||
Connection,
|
||||
ConnectionAckResult,
|
||||
ConnectionAuthPayload,
|
||||
ConnectionAuthResult,
|
||||
ConnectionChannel,
|
||||
ConnectionChannelMap,
|
||||
ConnectionChannelOptions,
|
||||
ConnectionCloseEvent,
|
||||
ConnectionConnectResult,
|
||||
ConnectionFrame,
|
||||
ConnectionLogger,
|
||||
ConnectionMessageMeta,
|
||||
ConnectionOptions,
|
||||
ConnectionRequestOptions,
|
||||
ConnectionSendResult,
|
||||
ConnectionSessionSource,
|
||||
ConnectionState,
|
||||
ConnectionStateChange,
|
||||
ConnectionTransport
|
||||
} from './types.ts';
|
||||
|
||||
interface ConnectionRuntime {
|
||||
readonly logger?: ConnectionLogger;
|
||||
readonly timers: TimerScheduler;
|
||||
readonly session?: ConnectionSessionSource;
|
||||
}
|
||||
|
||||
interface PendingAck {
|
||||
readonly timer: string;
|
||||
readonly resolve: (result: ConnectionAckResult<unknown>) => void;
|
||||
}
|
||||
|
||||
type GlobalListener = (frame: ConnectionFrame, meta: ConnectionMessageMeta) => void;
|
||||
type StateListener = (change: ConnectionStateChange) => void;
|
||||
|
||||
function createIdFactory(connectionName: string): () => string {
|
||||
let next = 0;
|
||||
return () => {
|
||||
next += 1;
|
||||
return [CONNECTION_ID_PREFIX, connectionName, String(next)].join(CONNECTION_ID_SEPARATOR);
|
||||
};
|
||||
}
|
||||
|
||||
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
||||
return typeof value === 'object' && value !== null && 'then' in value;
|
||||
}
|
||||
|
||||
function isClosedLike(state: ConnectionState): boolean {
|
||||
return (
|
||||
state === CONNECTION_STATE_CLOSED ||
|
||||
state === CONNECTION_STATE_FAILED ||
|
||||
state === CONNECTION_STATE_IDLE
|
||||
);
|
||||
}
|
||||
|
||||
export function createConnection<TChannels extends ConnectionChannelMap = ConnectionChannelMap>(
|
||||
name: string,
|
||||
options: ConnectionOptions<TChannels>,
|
||||
runtime: ConnectionRuntime
|
||||
): Connection<TChannels> {
|
||||
const serializer = options.serializer ?? jsonConnectionSerializer;
|
||||
const reconnectDisabled = options.reconnect === false;
|
||||
const reconnectOptions = reconnectDisabled ? undefined : options.reconnect;
|
||||
const heartbeatOptions = options.heartbeat === false ? false : options.heartbeat;
|
||||
const bufferOptions = options.buffer ?? {};
|
||||
const stateListeners = new Set<StateListener>();
|
||||
const globalListeners = new Set<GlobalListener>();
|
||||
const pendingAcks = new Map<string, PendingAck>();
|
||||
const bufferedFrames: ConnectionFrame[] = [];
|
||||
const channels = new Map<string, InternalConnectionChannel>();
|
||||
const detachBrowserListeners: (() => void)[] = [];
|
||||
const nextId = createIdFactory(name);
|
||||
const scope = loggerScope(name, options.loggerScope);
|
||||
|
||||
let transport: ConnectionTransport | null = null;
|
||||
let detachTransportListeners: (() => void)[] = [];
|
||||
let state: ConnectionState = CONNECTION_STATE_IDLE;
|
||||
let generation = 0;
|
||||
let error: unknown | null = null;
|
||||
let openedAt: number | null = null;
|
||||
let closedAt: number | null = null;
|
||||
let lastMessageAt: number | null = null;
|
||||
let reconnectAttempt = 0;
|
||||
let disposed = false;
|
||||
let intentionalClose = false;
|
||||
let connectPromise: Promise<ConnectionConnectResult> | null = null;
|
||||
|
||||
function now(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function logDebug(message: string, meta?: unknown): void {
|
||||
runtime.logger?.debug?.(scope, message, meta);
|
||||
}
|
||||
|
||||
function logWarn(message: string, meta?: unknown): void {
|
||||
runtime.logger?.warn?.(scope, message, meta);
|
||||
}
|
||||
|
||||
function logError(message: string, meta?: unknown): void {
|
||||
runtime.logger?.error?.(scope, message, meta);
|
||||
}
|
||||
|
||||
function emitState(to: ConnectionState, changeError?: unknown): void {
|
||||
if (state === to && changeError === undefined) return;
|
||||
const from = state;
|
||||
state = to;
|
||||
const change: ConnectionStateChange = {
|
||||
connection: name,
|
||||
from,
|
||||
to,
|
||||
generation,
|
||||
error: changeError,
|
||||
at: now()
|
||||
};
|
||||
for (const listener of [...stateListeners]) {
|
||||
try {
|
||||
listener(change);
|
||||
} catch (err) {
|
||||
logError(listenerThrewMessage(to), { error: err });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTransport(): ConnectionTransport {
|
||||
return typeof options.transport === 'function' ? options.transport() : options.transport;
|
||||
}
|
||||
|
||||
function detachTransport(): void {
|
||||
for (const detach of detachTransportListeners) detach();
|
||||
detachTransportListeners = [];
|
||||
}
|
||||
|
||||
function attachTransport(next: ConnectionTransport): void {
|
||||
detachTransport();
|
||||
detachTransportListeners = [
|
||||
next.onOpen(handleTransportOpen),
|
||||
next.onMessage(handleTransportMessage),
|
||||
next.onClose(handleTransportClose),
|
||||
next.onError(handleTransportError)
|
||||
];
|
||||
}
|
||||
|
||||
function markOpen(): void {
|
||||
if (state === CONNECTION_STATE_OPEN) return;
|
||||
generation += 1;
|
||||
error = null;
|
||||
openedAt = now();
|
||||
closedAt = null;
|
||||
reconnectAttempt = 0;
|
||||
emitState(CONNECTION_STATE_OPEN);
|
||||
startHeartbeat();
|
||||
}
|
||||
|
||||
function markClosed(nextState: ConnectionState, cause?: unknown): void {
|
||||
closedAt = now();
|
||||
stopHeartbeat();
|
||||
resolvePendingAcks({ ok: false, reason: CONNECTION_ACK_REASON_CLOSED });
|
||||
emitState(nextState, cause);
|
||||
}
|
||||
|
||||
function handleTransportOpen(): void {
|
||||
markOpen();
|
||||
}
|
||||
|
||||
function handleTransportMessage(raw: string | ArrayBuffer): void {
|
||||
lastMessageAt = now();
|
||||
cancelTimer(TIMER_KEY_HEARTBEAT_TIMEOUT);
|
||||
let frame: ConnectionFrame;
|
||||
try {
|
||||
frame = serializer.decode(raw);
|
||||
assertConnectionFrame(frame);
|
||||
} catch (err) {
|
||||
error = err;
|
||||
logError(LOG_MSG_FRAME_DECODE_FAILED, { error: err });
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.replyTo !== undefined) {
|
||||
resolveAckFromFrame(frame);
|
||||
return;
|
||||
}
|
||||
if (frame.type === CONNECTION_FRAME_TYPE_PONG) return;
|
||||
|
||||
const meta: ConnectionMessageMeta = {
|
||||
connection: name,
|
||||
topic: frame.topic,
|
||||
receivedAt: lastMessageAt,
|
||||
generation
|
||||
};
|
||||
|
||||
if (frame.topic !== undefined) {
|
||||
channels.get(frame.topic)?.receive(frame, meta);
|
||||
}
|
||||
emitGlobal(frame, meta);
|
||||
}
|
||||
|
||||
function handleTransportClose(event: ConnectionCloseEvent): void {
|
||||
if (disposed) return;
|
||||
if (intentionalClose) {
|
||||
markClosed(CONNECTION_STATE_CLOSED);
|
||||
return;
|
||||
}
|
||||
error = event;
|
||||
if (shouldReconnect()) {
|
||||
markClosed(CONNECTION_STATE_RECONNECTING, event);
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
markClosed(CONNECTION_STATE_FAILED, event);
|
||||
}
|
||||
|
||||
function handleTransportError(err: unknown): void {
|
||||
error = err;
|
||||
logError(LOG_MSG_TRANSPORT_ERROR, { error: err });
|
||||
}
|
||||
|
||||
function emitGlobal(frame: ConnectionFrame, meta: ConnectionMessageMeta): void {
|
||||
for (const listener of [...globalListeners]) {
|
||||
try {
|
||||
listener(frame, meta);
|
||||
} catch (err) {
|
||||
logError(listenerThrewMessage(frame.type), { error: err });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cancelTimer(kind: string, id?: string): void {
|
||||
runtime.timers.cancel(timerKey(name, kind, id));
|
||||
}
|
||||
|
||||
function scheduleTimer(
|
||||
kind: string,
|
||||
delayMs: number,
|
||||
task: () => void | Promise<void>,
|
||||
id?: string
|
||||
): void {
|
||||
runtime.timers.schedule(timerKey(name, kind, id), delayMs, task, { replace: true });
|
||||
}
|
||||
|
||||
function shouldReconnect(): boolean {
|
||||
if (disposed || intentionalClose) return false;
|
||||
if (reconnectDisabled) return false;
|
||||
return reconnectOptions?.enabled ?? DEFAULT_RECONNECT_ENABLED;
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (!shouldReconnect()) return;
|
||||
const maxAttempts = reconnectOptions?.maxAttempts;
|
||||
if (maxAttempts !== undefined && reconnectAttempt >= maxAttempts) {
|
||||
logWarn(LOG_MSG_RECONNECT_EXHAUSTED, { reconnectAttempt, maxAttempts });
|
||||
error = new Error(LOG_MSG_RECONNECT_EXHAUSTED);
|
||||
emitState(CONNECTION_STATE_FAILED, error);
|
||||
return;
|
||||
}
|
||||
reconnectAttempt += 1;
|
||||
const delayMs = computeBackoffDelay(reconnectAttempt - 1, {
|
||||
minDelayMs: reconnectOptions?.minDelayMs ?? DEFAULT_RECONNECT_MIN_DELAY_MS,
|
||||
maxDelayMs: reconnectOptions?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS,
|
||||
factor: reconnectOptions?.factor ?? DEFAULT_RECONNECT_FACTOR,
|
||||
jitterMs: reconnectOptions?.jitterMs ?? DEFAULT_RECONNECT_JITTER_MS
|
||||
});
|
||||
scheduleTimer(TIMER_KEY_RECONNECT, delayMs, () => {
|
||||
void connectInternal(true);
|
||||
});
|
||||
}
|
||||
|
||||
function startHeartbeat(): void {
|
||||
if (heartbeatOptions === false) return;
|
||||
if ((heartbeatOptions?.enabled ?? DEFAULT_HEARTBEAT_ENABLED) === false) return;
|
||||
const intervalMs = heartbeatOptions?.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
||||
runtime.timers.interval(
|
||||
timerKey(name, TIMER_KEY_HEARTBEAT),
|
||||
intervalMs,
|
||||
() => {
|
||||
void sendHeartbeat();
|
||||
},
|
||||
{ replace: true, awaitTask: false }
|
||||
);
|
||||
}
|
||||
|
||||
function stopHeartbeat(): void {
|
||||
cancelTimer(TIMER_KEY_HEARTBEAT);
|
||||
cancelTimer(TIMER_KEY_HEARTBEAT_TIMEOUT);
|
||||
}
|
||||
|
||||
async function sendHeartbeat(): Promise<void> {
|
||||
if (!isConnected()) return;
|
||||
const pingType = heartbeatOptions === false ? undefined : heartbeatOptions?.pingType;
|
||||
const result = await sendFrame(
|
||||
createFrame({
|
||||
type: pingType ?? CONNECTION_FRAME_TYPE_PING,
|
||||
payload: undefined
|
||||
}),
|
||||
false
|
||||
);
|
||||
if (!result.ok) return;
|
||||
const timeoutMs =
|
||||
heartbeatOptions === false
|
||||
? DEFAULT_HEARTBEAT_TIMEOUT_MS
|
||||
: (heartbeatOptions?.timeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS);
|
||||
scheduleTimer(TIMER_KEY_HEARTBEAT_TIMEOUT, timeoutMs, () => {
|
||||
logWarn(LOG_MSG_HEARTBEAT_TIMEOUT);
|
||||
transport?.close(undefined, CONNECTION_CLOSE_REASON_HEARTBEAT_TIMEOUT);
|
||||
});
|
||||
}
|
||||
|
||||
function isConnected(): boolean {
|
||||
return state === CONNECTION_STATE_OPEN && transport?.state === CONNECTION_TRANSPORT_STATE_OPEN;
|
||||
}
|
||||
|
||||
function resolvePendingAcks(result: ConnectionAckResult<unknown>): void {
|
||||
for (const id of [...pendingAcks.keys()]) resolveAck(id, result);
|
||||
}
|
||||
|
||||
function resolveAck(id: string, result: ConnectionAckResult<unknown>): void {
|
||||
const pending = pendingAcks.get(id);
|
||||
if (pending === undefined) return;
|
||||
pendingAcks.delete(id);
|
||||
runtime.timers.cancel(pending.timer);
|
||||
pending.resolve(result);
|
||||
}
|
||||
|
||||
function resolveAckFromFrame(frame: ConnectionFrame): void {
|
||||
if (frame.replyTo === undefined) return;
|
||||
if (frame.error !== undefined) {
|
||||
resolveAck(frame.replyTo, {
|
||||
ok: false,
|
||||
reason: CONNECTION_ACK_REASON_REJECTED,
|
||||
error: frame.error
|
||||
});
|
||||
return;
|
||||
}
|
||||
resolveAck(frame.replyTo, { ok: true, payload: frame.payload });
|
||||
}
|
||||
|
||||
function mapSendFailureToAck(result: ConnectionSendResult): ConnectionAckResult<unknown> {
|
||||
if (result.ok) return { ok: true, payload: undefined };
|
||||
if (result.reason === CONNECTION_SEND_REASON_CLOSED) {
|
||||
return { ok: false, reason: CONNECTION_ACK_REASON_CLOSED, error: result.error };
|
||||
}
|
||||
return { ok: false, reason: CONNECTION_ACK_REASON_TRANSPORT_ERROR, error: result.error };
|
||||
}
|
||||
|
||||
function canBuffer(frame: ConnectionFrame, allowBuffer: boolean): boolean {
|
||||
if (!allowBuffer) return false;
|
||||
if (frame.ack === true) return false;
|
||||
return bufferOptions.policy === CONNECTION_BUFFER_POLICY_BUFFER;
|
||||
}
|
||||
|
||||
function bufferFrame(frame: ConnectionFrame): ConnectionSendResult {
|
||||
const maxMessages = bufferOptions.maxMessages ?? DEFAULT_BUFFER_MAX_MESSAGES;
|
||||
if (bufferedFrames.length >= maxMessages) {
|
||||
return { ok: false, reason: CONNECTION_SEND_REASON_BUFFER_FULL };
|
||||
}
|
||||
bufferedFrames.push(frame);
|
||||
return { ok: true, id: frame.id };
|
||||
}
|
||||
|
||||
async function flushBuffer(): Promise<void> {
|
||||
while (isConnected() && bufferedFrames.length > 0) {
|
||||
const frame = bufferedFrames.shift();
|
||||
if (frame === undefined) return;
|
||||
const result = await sendFrame(frame, false);
|
||||
if (!result.ok) {
|
||||
bufferedFrames.unshift(frame);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendFrame(
|
||||
frame: ConnectionFrame,
|
||||
allowBuffer: boolean
|
||||
): Promise<ConnectionSendResult> {
|
||||
try {
|
||||
createFrame(frame);
|
||||
} catch (err) {
|
||||
return { ok: false, reason: CONNECTION_SEND_REASON_INVALID_FRAME, error: err };
|
||||
}
|
||||
|
||||
if (!isConnected()) {
|
||||
if (canBuffer(frame, allowBuffer)) return bufferFrame(frame);
|
||||
if (bufferOptions.policy === CONNECTION_BUFFER_POLICY_DROP) {
|
||||
return { ok: true, id: frame.id };
|
||||
}
|
||||
return { ok: false, reason: CONNECTION_SEND_REASON_CLOSED };
|
||||
}
|
||||
|
||||
const currentTransport = transport;
|
||||
if (currentTransport === null || !currentTransport.canSend) {
|
||||
return { ok: false, reason: CONNECTION_SEND_REASON_SEND_NOT_SUPPORTED };
|
||||
}
|
||||
const maxBytes = bufferOptions.maxBytes ?? DEFAULT_BUFFER_MAX_BYTES;
|
||||
if (currentTransport.bufferedAmount > maxBytes) {
|
||||
return { ok: false, reason: CONNECTION_SEND_REASON_BUFFER_FULL };
|
||||
}
|
||||
|
||||
let encoded: string | ArrayBuffer;
|
||||
try {
|
||||
encoded = serializer.encode(frame);
|
||||
} catch (err) {
|
||||
logError(LOG_MSG_FRAME_ENCODE_FAILED, { error: err });
|
||||
return { ok: false, reason: CONNECTION_SEND_REASON_SERIALIZE_FAILED, error: err };
|
||||
}
|
||||
|
||||
try {
|
||||
const maybe = currentTransport.send(encoded);
|
||||
if (isPromiseLike(maybe)) await maybe;
|
||||
return { ok: true, id: frame.id };
|
||||
} catch (err) {
|
||||
error = err;
|
||||
logError(LOG_MSG_SEND_FAILED, { error: err });
|
||||
return { ok: false, reason: CONNECTION_SEND_REASON_TRANSPORT_ERROR, error: err };
|
||||
}
|
||||
}
|
||||
|
||||
function getAuthProvider():
|
||||
| (() => ConnectionAuthPayload | null | Promise<ConnectionAuthPayload | null>)
|
||||
| null {
|
||||
if (typeof options.auth === 'function') return options.auth;
|
||||
return options.auth?.getAuth ?? null;
|
||||
}
|
||||
|
||||
async function runAuth(): Promise<ConnectionAuthResult> {
|
||||
if (!isConnected()) return { ok: false, reason: CONNECTION_AUTH_REASON_CLOSED };
|
||||
const provider = getAuthProvider();
|
||||
if (provider === null) return { ok: false, reason: CONNECTION_AUTH_REASON_NO_PROVIDER };
|
||||
let payload: ConnectionAuthPayload | null;
|
||||
try {
|
||||
payload = await provider();
|
||||
} catch (err) {
|
||||
return { ok: false, reason: CONNECTION_AUTH_REASON_REJECTED, error: err };
|
||||
}
|
||||
if (payload === null) return { ok: false, reason: CONNECTION_AUTH_REASON_NO_PROVIDER };
|
||||
const authOptions = typeof options.auth === 'function' ? undefined : options.auth;
|
||||
const authType =
|
||||
typeof options.auth === 'function' ? CONNECTION_FRAME_TYPE_AUTH : authOptions?.authType;
|
||||
const result = await requestFrame<ConnectionAuthPayload, unknown>(
|
||||
authType ?? CONNECTION_FRAME_TYPE_AUTH,
|
||||
payload,
|
||||
{ timeoutMs: authOptions?.timeoutMs }
|
||||
);
|
||||
if (result.ok) return { ok: true };
|
||||
if (result.reason === CONNECTION_ACK_REASON_TIMEOUT) {
|
||||
return { ok: false, reason: CONNECTION_AUTH_REASON_TIMEOUT, error: result.error };
|
||||
}
|
||||
if (result.reason === CONNECTION_ACK_REASON_CLOSED) {
|
||||
return { ok: false, reason: CONNECTION_AUTH_REASON_CLOSED, error: result.error };
|
||||
}
|
||||
if (result.reason === CONNECTION_ACK_REASON_REJECTED) {
|
||||
return { ok: false, reason: CONNECTION_AUTH_REASON_REJECTED, error: result.error };
|
||||
}
|
||||
return { ok: false, reason: CONNECTION_AUTH_REASON_TRANSPORT_ERROR, error: result.error };
|
||||
}
|
||||
|
||||
async function requestFrame<TPayload, TResult>(
|
||||
type: string,
|
||||
payload: TPayload,
|
||||
requestOptions: ConnectionRequestOptions = {}
|
||||
): Promise<ConnectionAckResult<TResult>> {
|
||||
if (!isConnected()) return { ok: false, reason: CONNECTION_ACK_REASON_CLOSED };
|
||||
const id = requestOptions.id ?? nextId();
|
||||
const frame = createFrame({
|
||||
id,
|
||||
type,
|
||||
payload,
|
||||
topic: requestOptions.topic,
|
||||
ack: true,
|
||||
ts: requestOptions.ts
|
||||
});
|
||||
const timeoutMs = requestOptions.timeoutMs ?? DEFAULT_ACK_TIMEOUT_MS;
|
||||
const ackTimer = timerKey(name, TIMER_KEY_ACK, id);
|
||||
const promise = new Promise<ConnectionAckResult<TResult>>((resolve) => {
|
||||
pendingAcks.set(id, {
|
||||
timer: ackTimer,
|
||||
resolve: resolve as (result: ConnectionAckResult<unknown>) => void
|
||||
});
|
||||
runtime.timers.schedule(
|
||||
ackTimer,
|
||||
timeoutMs,
|
||||
() => {
|
||||
resolveAck(id, { ok: false, reason: CONNECTION_ACK_REASON_TIMEOUT });
|
||||
},
|
||||
{ replace: true }
|
||||
);
|
||||
});
|
||||
const result = await sendFrame(frame, requestOptions.buffer === true);
|
||||
if (!result.ok) resolveAck(id, mapSendFailureToAck(result));
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function joinConfiguredChannels(reconnect: boolean): Promise<void> {
|
||||
const configured = (options.channels ?? {}) as Record<
|
||||
string,
|
||||
ConnectionChannelOptions | undefined
|
||||
>;
|
||||
for (const channelName of Object.keys(configured)) {
|
||||
const configuredOptions = configured[channelName] ?? {};
|
||||
const current = connection.channel(
|
||||
channelName,
|
||||
configuredOptions
|
||||
) as InternalConnectionChannel;
|
||||
if (configuredOptions.autoJoin === true || (reconnect && current.shouldRejoin)) {
|
||||
await current.join();
|
||||
}
|
||||
}
|
||||
if (!reconnect) return;
|
||||
for (const current of channels.values()) {
|
||||
if (current.shouldRejoin) await current.rejoin();
|
||||
}
|
||||
}
|
||||
|
||||
async function connectInternal(reconnect: boolean): Promise<ConnectionConnectResult> {
|
||||
if (disposed) {
|
||||
return {
|
||||
ok: false,
|
||||
state,
|
||||
reason: CONNECTION_CONNECT_REASON_DISPOSED
|
||||
};
|
||||
}
|
||||
if (state === CONNECTION_STATE_OPEN && isConnected()) {
|
||||
return { ok: true, state, reused: true };
|
||||
}
|
||||
if (connectPromise !== null) return connectPromise;
|
||||
|
||||
connectPromise = (async () => {
|
||||
intentionalClose = false;
|
||||
cancelTimer(TIMER_KEY_RECONNECT);
|
||||
const nextTransport = resolveTransport();
|
||||
transport = nextTransport;
|
||||
attachTransport(nextTransport);
|
||||
emitState(reconnect ? CONNECTION_STATE_RECONNECTING : CONNECTION_STATE_CONNECTING);
|
||||
try {
|
||||
await nextTransport.open();
|
||||
markOpen();
|
||||
if (getAuthProvider() !== null) {
|
||||
const auth = await runAuth();
|
||||
if (!auth.ok) {
|
||||
error = auth.error ?? auth.reason;
|
||||
logWarn(LOG_MSG_AUTH_FAILED, auth);
|
||||
detachTransport();
|
||||
transport?.close(undefined, CONNECTION_CLOSE_REASON_AUTH_FAILED);
|
||||
markClosed(CONNECTION_STATE_FAILED, error);
|
||||
return {
|
||||
ok: false,
|
||||
state,
|
||||
reason: CONNECTION_CONNECT_REASON_AUTH_FAILED,
|
||||
error
|
||||
};
|
||||
}
|
||||
}
|
||||
await flushBuffer();
|
||||
await joinConfiguredChannels(reconnect);
|
||||
return { ok: true, state };
|
||||
} catch (err) {
|
||||
error = err;
|
||||
logError(LOG_MSG_CONNECT_FAILED, { error: err });
|
||||
emitState(CONNECTION_STATE_FAILED, err);
|
||||
return {
|
||||
ok: false,
|
||||
state,
|
||||
reason: CONNECTION_CONNECT_REASON_TRANSPORT_ERROR,
|
||||
error: err
|
||||
};
|
||||
} finally {
|
||||
connectPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return connectPromise;
|
||||
}
|
||||
|
||||
function closeTransport(reason: string): void {
|
||||
intentionalClose = true;
|
||||
cancelTimer(TIMER_KEY_RECONNECT);
|
||||
stopHeartbeat();
|
||||
resolvePendingAcks({ ok: false, reason: CONNECTION_ACK_REASON_CLOSED });
|
||||
emitState(CONNECTION_STATE_CLOSING);
|
||||
transport?.close(undefined, reason);
|
||||
markClosed(CONNECTION_STATE_CLOSED);
|
||||
}
|
||||
|
||||
function wireBrowserReconnect(): void {
|
||||
if (reconnectDisabled) return;
|
||||
const reconnectOnOnline = reconnectOptions?.reconnectOnOnline ?? DEFAULT_RECONNECT_ON_ONLINE;
|
||||
const reconnectOnVisible = reconnectOptions?.reconnectOnVisible ?? DEFAULT_RECONNECT_ON_VISIBLE;
|
||||
const target = globalThis as {
|
||||
addEventListener?: (type: string, listener: () => void) => void;
|
||||
removeEventListener?: (type: string, listener: () => void) => void;
|
||||
document?: {
|
||||
readonly visibilityState?: string;
|
||||
addEventListener?: (type: string, listener: () => void) => void;
|
||||
removeEventListener?: (type: string, listener: () => void) => void;
|
||||
};
|
||||
};
|
||||
if (reconnectOnOnline && target.addEventListener && target.removeEventListener) {
|
||||
const onOnline = (): void => {
|
||||
if (intentionalClose || disposed || !isClosedLike(state)) return;
|
||||
logDebug(LOG_MSG_BROWSER_RECONNECT);
|
||||
void connectInternal(true);
|
||||
};
|
||||
target.addEventListener(BROWSER_EVENT_ONLINE, onOnline);
|
||||
detachBrowserListeners.push(() => {
|
||||
target.removeEventListener?.(BROWSER_EVENT_ONLINE, onOnline);
|
||||
});
|
||||
}
|
||||
if (
|
||||
reconnectOnVisible &&
|
||||
target.document?.addEventListener &&
|
||||
target.document.removeEventListener
|
||||
) {
|
||||
const onVisible = (): void => {
|
||||
if (target.document?.visibilityState !== DOCUMENT_VISIBILITY_VISIBLE) return;
|
||||
if (intentionalClose || disposed || !isClosedLike(state)) return;
|
||||
logDebug(LOG_MSG_BROWSER_RECONNECT);
|
||||
void connectInternal(true);
|
||||
};
|
||||
target.document.addEventListener(BROWSER_EVENT_VISIBILITY_CHANGE, onVisible);
|
||||
detachBrowserListeners.push(() => {
|
||||
target.document?.removeEventListener?.(BROWSER_EVENT_VISIBILITY_CHANGE, onVisible);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function wireSession(): () => void {
|
||||
const sessionOptions = options.session;
|
||||
if (
|
||||
sessionOptions === undefined ||
|
||||
sessionOptions === false ||
|
||||
sessionOptions.enabled === false
|
||||
) {
|
||||
return () => {};
|
||||
}
|
||||
const source = runtime.session;
|
||||
if (source === undefined) return () => {};
|
||||
return source.onChange((change) => {
|
||||
if (change.event === SESSION_EVENT_REFRESHED && sessionOptions.reauthOnRefresh !== false) {
|
||||
logDebug(LOG_MSG_SESSION_REFRESHED);
|
||||
void connection.reauthenticate().then((result) => {
|
||||
if (!result.ok) logWarn(LOG_MSG_REAUTH_FAILED, result);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(change.event === SESSION_EVENT_EXPIRED || change.event === SESSION_EVENT_REVOKED) &&
|
||||
sessionOptions.disconnectOnExpire !== false
|
||||
) {
|
||||
logWarn(
|
||||
change.event === SESSION_EVENT_EXPIRED ? LOG_MSG_SESSION_EXPIRED : LOG_MSG_SESSION_REVOKED
|
||||
);
|
||||
connection.disconnect(CONNECTION_CLOSE_REASON_SESSION_EXPIRED);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function reportChannelListenerError(event: string, err: unknown): void {
|
||||
logError(listenerThrewMessage(event), { error: err });
|
||||
}
|
||||
|
||||
let detachSession = (): void => {};
|
||||
|
||||
const connection: Connection<TChannels> = {
|
||||
name,
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
get connected() {
|
||||
return isConnected();
|
||||
},
|
||||
get generation() {
|
||||
return generation;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
get openedAt() {
|
||||
return openedAt;
|
||||
},
|
||||
get closedAt() {
|
||||
return closedAt;
|
||||
},
|
||||
get lastMessageAt() {
|
||||
return lastMessageAt;
|
||||
},
|
||||
get reconnectAttempt() {
|
||||
return reconnectAttempt;
|
||||
},
|
||||
connect() {
|
||||
return connectInternal(false);
|
||||
},
|
||||
disconnect(reason = CONNECTION_CLOSE_REASON_DISCONNECT) {
|
||||
if (disposed) return;
|
||||
closeTransport(reason);
|
||||
},
|
||||
reconnect(reason = CONNECTION_CLOSE_REASON_RECONNECT) {
|
||||
if (disposed) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
state,
|
||||
reason: CONNECTION_CONNECT_REASON_DISPOSED
|
||||
});
|
||||
}
|
||||
closeTransport(reason);
|
||||
intentionalClose = false;
|
||||
return connectInternal(true);
|
||||
},
|
||||
reauthenticate() {
|
||||
return runAuth();
|
||||
},
|
||||
send(type, payload, sendOptions = {}) {
|
||||
const frame = createFrame({
|
||||
id: sendOptions.id,
|
||||
type,
|
||||
payload,
|
||||
topic: sendOptions.topic,
|
||||
ack: sendOptions.ack,
|
||||
ts: sendOptions.ts
|
||||
});
|
||||
return sendFrame(frame, sendOptions.buffer !== false);
|
||||
},
|
||||
request(type, payload, requestOptions = {}) {
|
||||
return requestFrame(type, payload, requestOptions);
|
||||
},
|
||||
channel<TEvents extends Record<string, unknown> = Record<string, unknown>>(
|
||||
channelName: string,
|
||||
channelOptions: ConnectionChannelOptions = {}
|
||||
): ConnectionChannel<TEvents> {
|
||||
const existing = channels.get(channelName);
|
||||
if (existing !== undefined) return existing as ConnectionChannel<TEvents>;
|
||||
const created = createConnectionChannel<TEvents>(channelName, channelOptions, {
|
||||
connection: connection as unknown as Connection<Record<string, TEvents>>,
|
||||
reportListenerError: reportChannelListenerError
|
||||
});
|
||||
channels.set(channelName, created as InternalConnectionChannel);
|
||||
return created;
|
||||
},
|
||||
channels() {
|
||||
return [...channels.values()];
|
||||
},
|
||||
hasChannel(channelName) {
|
||||
return channels.has(channelName);
|
||||
},
|
||||
async leaveChannel(channelName) {
|
||||
await channels.get(channelName)?.leave();
|
||||
},
|
||||
onState(listener) {
|
||||
stateListeners.add(listener);
|
||||
return () => {
|
||||
stateListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
onAny(listener) {
|
||||
globalListeners.add(listener);
|
||||
return () => {
|
||||
globalListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
closeTransport(CONNECTION_CLOSE_REASON_DISPOSE);
|
||||
detachSession();
|
||||
for (const detach of detachBrowserListeners) detach();
|
||||
detachBrowserListeners.length = 0;
|
||||
detachTransport();
|
||||
for (const current of channels.values()) current.dispose();
|
||||
channels.clear();
|
||||
stateListeners.clear();
|
||||
globalListeners.clear();
|
||||
bufferedFrames.length = 0;
|
||||
}
|
||||
};
|
||||
|
||||
detachSession = wireSession();
|
||||
wireBrowserReconnect();
|
||||
|
||||
return connection;
|
||||
}
|
||||
@ -0,0 +1,249 @@
|
||||
export const LOGGER_CATEGORY = 'conn';
|
||||
export const LOGGER_SCOPE_SEPARATOR = ':';
|
||||
|
||||
export const CONNECTION_STATE_IDLE = 'idle';
|
||||
export const CONNECTION_STATE_CONNECTING = 'connecting';
|
||||
export const CONNECTION_STATE_OPEN = 'open';
|
||||
export const CONNECTION_STATE_RECONNECTING = 'reconnecting';
|
||||
export const CONNECTION_STATE_CLOSING = 'closing';
|
||||
export const CONNECTION_STATE_CLOSED = 'closed';
|
||||
export const CONNECTION_STATE_FAILED = 'failed';
|
||||
|
||||
export const CONNECTION_STATES = [
|
||||
CONNECTION_STATE_IDLE,
|
||||
CONNECTION_STATE_CONNECTING,
|
||||
CONNECTION_STATE_OPEN,
|
||||
CONNECTION_STATE_RECONNECTING,
|
||||
CONNECTION_STATE_CLOSING,
|
||||
CONNECTION_STATE_CLOSED,
|
||||
CONNECTION_STATE_FAILED
|
||||
] as const;
|
||||
|
||||
export const CONNECTION_CHANNEL_STATE_IDLE = 'idle';
|
||||
export const CONNECTION_CHANNEL_STATE_JOINING = 'joining';
|
||||
export const CONNECTION_CHANNEL_STATE_JOINED = 'joined';
|
||||
export const CONNECTION_CHANNEL_STATE_LEAVING = 'leaving';
|
||||
export const CONNECTION_CHANNEL_STATE_LEFT = 'left';
|
||||
export const CONNECTION_CHANNEL_STATE_FAILED = 'failed';
|
||||
|
||||
export const CONNECTION_TRANSPORT_STATE_IDLE = 'idle';
|
||||
export const CONNECTION_TRANSPORT_STATE_OPENING = 'opening';
|
||||
export const CONNECTION_TRANSPORT_STATE_OPEN = 'open';
|
||||
export const CONNECTION_TRANSPORT_STATE_CLOSING = 'closing';
|
||||
export const CONNECTION_TRANSPORT_STATE_CLOSED = 'closed';
|
||||
|
||||
export const CONNECTION_TRANSPORT_STATES = [
|
||||
CONNECTION_TRANSPORT_STATE_IDLE,
|
||||
CONNECTION_TRANSPORT_STATE_OPENING,
|
||||
CONNECTION_TRANSPORT_STATE_OPEN,
|
||||
CONNECTION_TRANSPORT_STATE_CLOSING,
|
||||
CONNECTION_TRANSPORT_STATE_CLOSED
|
||||
] as const;
|
||||
|
||||
export const TRANSPORT_KIND_WEBSOCKET = 'websocket';
|
||||
export const TRANSPORT_KIND_MOCK = 'mock';
|
||||
|
||||
export const FRAME_KEY_ID = 'id';
|
||||
export const FRAME_KEY_TYPE = 'type';
|
||||
export const FRAME_KEY_TOPIC = 'topic';
|
||||
export const FRAME_KEY_PAYLOAD = 'payload';
|
||||
export const FRAME_KEY_TS = 'ts';
|
||||
export const FRAME_KEY_ACK = 'ack';
|
||||
export const FRAME_KEY_REPLY_TO = 'replyTo';
|
||||
export const FRAME_KEY_ERROR = 'error';
|
||||
|
||||
export const DEFAULT_ACK_TIMEOUT_MS = 10_000;
|
||||
export const DEFAULT_RECONNECT_MIN_DELAY_MS = 500;
|
||||
export const DEFAULT_RECONNECT_MAX_DELAY_MS = 15_000;
|
||||
export const DEFAULT_RECONNECT_FACTOR = 1.8;
|
||||
export const DEFAULT_RECONNECT_JITTER_MS = 500;
|
||||
export const DEFAULT_HEARTBEAT_INTERVAL_MS = 25_000;
|
||||
export const DEFAULT_HEARTBEAT_TIMEOUT_MS = 10_000;
|
||||
export const DEFAULT_BUFFER_MAX_MESSAGES = 100;
|
||||
export const DEFAULT_BUFFER_MAX_BYTES = 1_000_000;
|
||||
|
||||
export const DEFAULT_RECONNECT_ENABLED = true;
|
||||
export const DEFAULT_RECONNECT_ON_VISIBLE = true;
|
||||
export const DEFAULT_RECONNECT_ON_ONLINE = true;
|
||||
export const DEFAULT_HEARTBEAT_ENABLED = true;
|
||||
|
||||
export const CONNECTION_BUFFER_POLICY_BUFFER = 'buffer';
|
||||
export const CONNECTION_BUFFER_POLICY_DROP = 'drop';
|
||||
export const CONNECTION_BUFFER_POLICY_FAIL = 'fail';
|
||||
|
||||
export const CONNECTION_SEND_REASON_CLOSED = 'closed';
|
||||
export const CONNECTION_SEND_REASON_SEND_NOT_SUPPORTED = 'send_not_supported';
|
||||
export const CONNECTION_SEND_REASON_BUFFER_FULL = 'buffer_full';
|
||||
export const CONNECTION_SEND_REASON_INVALID_FRAME = 'invalid_frame';
|
||||
export const CONNECTION_SEND_REASON_SERIALIZE_FAILED = 'serialize_failed';
|
||||
export const CONNECTION_SEND_REASON_TRANSPORT_ERROR = 'transport_error';
|
||||
|
||||
export const CONNECTION_ACK_REASON_TIMEOUT = 'timeout';
|
||||
export const CONNECTION_ACK_REASON_CLOSED = 'closed';
|
||||
export const CONNECTION_ACK_REASON_REJECTED = 'rejected';
|
||||
export const CONNECTION_ACK_REASON_TRANSPORT_ERROR = 'transport_error';
|
||||
export const CONNECTION_ACK_REASON_INVALID_REPLY = 'invalid_reply';
|
||||
|
||||
export const CONNECTION_AUTH_REASON_NO_PROVIDER = 'no_auth_provider';
|
||||
export const CONNECTION_AUTH_REASON_CLOSED = 'closed';
|
||||
export const CONNECTION_AUTH_REASON_REJECTED = 'rejected';
|
||||
export const CONNECTION_AUTH_REASON_TIMEOUT = 'timeout';
|
||||
export const CONNECTION_AUTH_REASON_TRANSPORT_ERROR = 'transport_error';
|
||||
|
||||
export const CONNECTION_CONNECT_REASON_TRANSPORT_ERROR = 'transport_error';
|
||||
export const CONNECTION_CONNECT_REASON_AUTH_FAILED = 'auth_failed';
|
||||
export const CONNECTION_CONNECT_REASON_DISPOSED = 'disposed';
|
||||
export const CONNECTION_CONNECT_REASON_RECONNECT_EXHAUSTED = 'reconnect_exhausted';
|
||||
|
||||
export const CONNECTION_CHANNEL_JOIN_REASON_CLOSED = 'closed';
|
||||
export const CONNECTION_CHANNEL_JOIN_REASON_REJECTED = 'rejected';
|
||||
export const CONNECTION_CHANNEL_JOIN_REASON_TRANSPORT_ERROR = 'transport_error';
|
||||
|
||||
export const CONNECTION_EVENT_STATE = 'state';
|
||||
export const CONNECTION_EVENT_MESSAGE = 'message';
|
||||
export const CONNECTION_EVENT_CHANNELS = 'channels';
|
||||
export const CONNECTION_EVENT_DISPOSED = 'disposed';
|
||||
|
||||
export const CONNECTION_METHOD_CONNECT = 'connect';
|
||||
export const CONNECTION_METHOD_DISCONNECT = 'disconnect';
|
||||
export const CONNECTION_METHOD_RECONNECT = 'reconnect';
|
||||
export const CONNECTION_METHOD_SEND = 'send';
|
||||
export const CONNECTION_METHOD_REQUEST = 'request';
|
||||
export const CONNECTION_METHOD_CREATE_CONNECTION = 'createConnection';
|
||||
export const CONNECTION_METHOD_CONNECTION = 'connection';
|
||||
export const CONNECTION_METHOD_OPEN_CONNECTION = 'openConnection';
|
||||
export const CONNECTION_METHOD_CLOSE_CONNECTION = 'closeConnection';
|
||||
export const CONNECTION_METHOD_RECONNECT_CONNECTION = 'reconnectConnection';
|
||||
export const CONNECTION_METHOD_OPEN_ALL = 'openAll';
|
||||
export const CONNECTION_METHOD_CLOSE_ALL = 'closeAll';
|
||||
export const CONNECTION_METHOD_RECONNECT_ALL = 'reconnectAll';
|
||||
export const CONNECTION_METHOD_REAUTHENTICATE = 'reauthenticate';
|
||||
export const CONNECTION_METHOD_JOIN = 'join';
|
||||
export const CONNECTION_METHOD_LEAVE = 'leave';
|
||||
|
||||
export const CONNECTION_FRAME_TYPE_AUTH = 'conn.auth';
|
||||
export const CONNECTION_FRAME_TYPE_JOIN = 'conn.join';
|
||||
export const CONNECTION_FRAME_TYPE_LEAVE = 'conn.leave';
|
||||
export const CONNECTION_FRAME_TYPE_PING = 'conn.ping';
|
||||
export const CONNECTION_FRAME_TYPE_PONG = 'conn.pong';
|
||||
|
||||
export const CONNECTION_ID_PREFIX = 'conn';
|
||||
export const CONNECTION_ID_SEPARATOR = '-';
|
||||
|
||||
export const CONNECTION_CLOSE_REASON_DISCONNECT = 'disconnect';
|
||||
export const CONNECTION_CLOSE_REASON_RECONNECT = 'reconnect';
|
||||
export const CONNECTION_CLOSE_REASON_DISPOSE = 'dispose';
|
||||
export const CONNECTION_CLOSE_REASON_HEARTBEAT_TIMEOUT = 'heartbeat_timeout';
|
||||
export const CONNECTION_CLOSE_REASON_SESSION_EXPIRED = 'session_expired';
|
||||
export const CONNECTION_CLOSE_REASON_AUTH_FAILED = 'auth_failed';
|
||||
|
||||
export const TIMER_KEY_RECONNECT = 'reconnect';
|
||||
export const TIMER_KEY_HEARTBEAT = 'heartbeat';
|
||||
export const TIMER_KEY_HEARTBEAT_TIMEOUT = 'heartbeat-timeout';
|
||||
export const TIMER_KEY_ACK = 'ack';
|
||||
|
||||
export const BROWSER_EVENT_OPEN = 'open';
|
||||
export const BROWSER_EVENT_MESSAGE = 'message';
|
||||
export const BROWSER_EVENT_CLOSE = 'close';
|
||||
export const BROWSER_EVENT_ERROR = 'error';
|
||||
export const BROWSER_EVENT_ONLINE = 'online';
|
||||
export const BROWSER_EVENT_VISIBILITY_CHANGE = 'visibilitychange';
|
||||
export const DOCUMENT_VISIBILITY_VISIBLE = 'visible';
|
||||
|
||||
export const WEBSOCKET_BINARY_TYPE_ARRAYBUFFER = 'arraybuffer';
|
||||
export const WEBSOCKET_READY_STATE_CONNECTING = 0;
|
||||
export const WEBSOCKET_READY_STATE_OPEN = 1;
|
||||
export const WEBSOCKET_READY_STATE_CLOSING = 2;
|
||||
export const WEBSOCKET_READY_STATE_CLOSED = 3;
|
||||
export const WEBSOCKET_CLOSE_CODE_NORMAL = 1000;
|
||||
export const WEBSOCKET_CLOSE_CODE_ABNORMAL = 1006;
|
||||
|
||||
export const MOCK_TRANSPORT_DEFAULT_CLOSE_CODE = WEBSOCKET_CLOSE_CODE_NORMAL;
|
||||
export const MOCK_TRANSPORT_DEFAULT_CLOSE_CLEAN = true;
|
||||
|
||||
export const SESSION_EVENT_REFRESHED = 'REFRESHED';
|
||||
export const SESSION_EVENT_EXPIRED = 'EXPIRED';
|
||||
export const SESSION_EVENT_REVOKED = 'REVOKED';
|
||||
|
||||
export const LOG_MSG_LISTENER_THREW_PREFIX = 'Listener threw on ';
|
||||
export const LOG_MSG_TRANSPORT_ERROR = 'transport error';
|
||||
export const LOG_MSG_RECONNECT_EXHAUSTED = 'reconnect attempts exhausted';
|
||||
export const LOG_MSG_CONNECT_FAILED = 'connect failed';
|
||||
export const LOG_MSG_SEND_FAILED = 'send failed';
|
||||
export const LOG_MSG_FRAME_DECODE_FAILED = 'frame decode failed';
|
||||
export const LOG_MSG_FRAME_ENCODE_FAILED = 'frame encode failed';
|
||||
export const LOG_MSG_AUTH_FAILED = 'auth failed';
|
||||
export const LOG_MSG_REAUTH_FAILED = 'reauthentication failed';
|
||||
export const LOG_MSG_HEARTBEAT_TIMEOUT = 'heartbeat timeout';
|
||||
export const LOG_MSG_SESSION_REVOKED = 'session revoked; disconnecting connection';
|
||||
export const LOG_MSG_SESSION_EXPIRED = 'session expired; disconnecting connection';
|
||||
export const LOG_MSG_SESSION_REFRESHED = 'session refreshed; reauthenticating connection';
|
||||
export const LOG_MSG_BROWSER_RECONNECT = 'browser lifecycle reconnect requested';
|
||||
|
||||
export const ERROR_PREFIX = '[conn] ';
|
||||
export const ERROR_NAME_DISPOSED = 'ConnDisposedError';
|
||||
export const ERROR_NAME_ALREADY_EXISTS = 'ConnConnectionAlreadyExistsError';
|
||||
export const ERROR_NAME_NOT_FOUND = 'ConnConnectionNotFoundError';
|
||||
export const ERROR_NAME_INVALID_NAME = 'ConnInvalidConnectionNameError';
|
||||
export const ERROR_NAME_INVALID_FRAME = 'ConnInvalidFrameError';
|
||||
export const ERROR_NAME_CHANNEL_ALREADY_EXISTS = 'ConnChannelAlreadyExistsError';
|
||||
export const ERROR_NAME_CHANNEL_NOT_FOUND = 'ConnChannelNotFoundError';
|
||||
|
||||
export const ERROR_MSG_DISPOSED_SUFFIX = '() called on a disposed connection engine';
|
||||
export const ERROR_MSG_ALREADY_EXISTS_PREFIX = 'connection already exists: ';
|
||||
export const ERROR_MSG_NOT_FOUND_PREFIX = 'connection not found: ';
|
||||
export const ERROR_MSG_INVALID_NAME_PREFIX = 'invalid connection name: ';
|
||||
export const ERROR_MSG_INVALID_FRAME = 'invalid connection frame';
|
||||
export const ERROR_MSG_FRAME_MUST_BE_OBJECT = 'Connection frame must be an object';
|
||||
export const ERROR_MSG_FRAME_TYPE_MUST_BE_STRING =
|
||||
'Connection frame type must be a non-empty string';
|
||||
export const ERROR_MSG_FRAME_ID_MUST_BE_STRING = 'Connection frame id must be a string';
|
||||
export const ERROR_MSG_FRAME_TOPIC_MUST_BE_STRING = 'Connection frame topic must be a string';
|
||||
export const ERROR_MSG_FRAME_REPLY_TO_MUST_BE_STRING = 'Connection frame replyTo must be a string';
|
||||
export const ERROR_MSG_FRAME_ACK_MUST_BE_BOOLEAN = 'Connection frame ack must be a boolean';
|
||||
export const ERROR_MSG_FRAME_TS_MUST_BE_NUMBER = 'Connection frame ts must be a number';
|
||||
export const ERROR_MSG_FRAME_ERROR_MUST_BE_OBJECT = 'Connection frame error must be an object';
|
||||
export const ERROR_MSG_JSON_DECODE_FAILED = 'Connection JSON payload is invalid';
|
||||
export const ERROR_MSG_WEBSOCKET_UNAVAILABLE = 'WebSocket is not available in this runtime';
|
||||
export const ERROR_MSG_CHANNEL_ALREADY_EXISTS_PREFIX = 'channel already exists: ';
|
||||
export const ERROR_MSG_CHANNEL_NOT_FOUND_PREFIX = 'channel not found: ';
|
||||
|
||||
export function disposedErrorMessage(method: string): string {
|
||||
return `${ERROR_PREFIX}${method}${ERROR_MSG_DISPOSED_SUFFIX}`;
|
||||
}
|
||||
|
||||
export function alreadyExistsErrorMessage(name: string): string {
|
||||
return `${ERROR_PREFIX}${ERROR_MSG_ALREADY_EXISTS_PREFIX}${name}`;
|
||||
}
|
||||
|
||||
export function notFoundErrorMessage(name: string): string {
|
||||
return `${ERROR_PREFIX}${ERROR_MSG_NOT_FOUND_PREFIX}${name}`;
|
||||
}
|
||||
|
||||
export function invalidNameErrorMessage(name: unknown): string {
|
||||
return `${ERROR_PREFIX}${ERROR_MSG_INVALID_NAME_PREFIX}${String(name)}`;
|
||||
}
|
||||
|
||||
export function channelAlreadyExistsErrorMessage(name: string): string {
|
||||
return `${ERROR_PREFIX}${ERROR_MSG_CHANNEL_ALREADY_EXISTS_PREFIX}${name}`;
|
||||
}
|
||||
|
||||
export function channelNotFoundErrorMessage(name: string): string {
|
||||
return `${ERROR_PREFIX}${ERROR_MSG_CHANNEL_NOT_FOUND_PREFIX}${name}`;
|
||||
}
|
||||
|
||||
export function listenerThrewMessage(event: string): string {
|
||||
return `${LOG_MSG_LISTENER_THREW_PREFIX}${event}`;
|
||||
}
|
||||
|
||||
export function loggerScope(name: string, topic?: string): string {
|
||||
return topic === undefined
|
||||
? `${LOGGER_CATEGORY}${LOGGER_SCOPE_SEPARATOR}${name}`
|
||||
: `${LOGGER_CATEGORY}${LOGGER_SCOPE_SEPARATOR}${name}${LOGGER_SCOPE_SEPARATOR}${topic}`;
|
||||
}
|
||||
|
||||
export function timerKey(connection: string, kind: string, id?: string): string {
|
||||
return id === undefined
|
||||
? `${LOGGER_CATEGORY}${LOGGER_SCOPE_SEPARATOR}${connection}${LOGGER_SCOPE_SEPARATOR}${kind}`
|
||||
: `${LOGGER_CATEGORY}${LOGGER_SCOPE_SEPARATOR}${connection}${LOGGER_SCOPE_SEPARATOR}${kind}${LOGGER_SCOPE_SEPARATOR}${id}`;
|
||||
}
|
||||
@ -0,0 +1,173 @@
|
||||
import { createEngineTimers } from '$timr';
|
||||
import { createConnection } from './connection.ts';
|
||||
import {
|
||||
CONNECTION_METHOD_CLOSE_ALL,
|
||||
CONNECTION_METHOD_CLOSE_CONNECTION,
|
||||
CONNECTION_METHOD_CONNECTION,
|
||||
CONNECTION_METHOD_CREATE_CONNECTION,
|
||||
CONNECTION_METHOD_OPEN_ALL,
|
||||
CONNECTION_METHOD_OPEN_CONNECTION,
|
||||
CONNECTION_METHOD_RECONNECT_ALL,
|
||||
CONNECTION_METHOD_RECONNECT_CONNECTION,
|
||||
alreadyExistsErrorMessage,
|
||||
disposedErrorMessage,
|
||||
invalidNameErrorMessage,
|
||||
notFoundErrorMessage
|
||||
} from './consts.ts';
|
||||
import {
|
||||
ConnConnectionAlreadyExistsError,
|
||||
ConnConnectionNotFoundError,
|
||||
ConnDisposedError,
|
||||
ConnInvalidConnectionNameError
|
||||
} from './errors.ts';
|
||||
import type {
|
||||
Connection,
|
||||
ConnectionChannelMap,
|
||||
ConnectionMap,
|
||||
ConnectionOptions,
|
||||
EngineConnections,
|
||||
EngineConnectionsOptions
|
||||
} from './types.ts';
|
||||
|
||||
function validateConnectionName(name: unknown): asserts name is string {
|
||||
if (typeof name !== 'string' || name.trim().length === 0) {
|
||||
throw new ConnInvalidConnectionNameError(invalidNameErrorMessage(name));
|
||||
}
|
||||
}
|
||||
|
||||
function mergeObject<T extends object>(
|
||||
defaults: T | undefined,
|
||||
next: T | undefined
|
||||
): T | undefined {
|
||||
if (defaults === undefined) return next;
|
||||
if (next === undefined) return defaults;
|
||||
return { ...defaults, ...next };
|
||||
}
|
||||
|
||||
function mergeConnectionOptions<TChannels extends ConnectionChannelMap>(
|
||||
defaults: Partial<ConnectionOptions> | undefined,
|
||||
options: ConnectionOptions<TChannels>
|
||||
): ConnectionOptions<TChannels> {
|
||||
const reconnect =
|
||||
options.reconnect === false
|
||||
? false
|
||||
: defaults?.reconnect === false
|
||||
? (options.reconnect ?? false)
|
||||
: mergeObject(defaults?.reconnect, options.reconnect);
|
||||
const heartbeat =
|
||||
options.heartbeat === false
|
||||
? false
|
||||
: defaults?.heartbeat === false
|
||||
? (options.heartbeat ?? false)
|
||||
: mergeObject(defaults?.heartbeat, options.heartbeat);
|
||||
return {
|
||||
...defaults,
|
||||
...options,
|
||||
reconnect,
|
||||
heartbeat,
|
||||
buffer: mergeObject(defaults?.buffer, options.buffer),
|
||||
channels: mergeObject(
|
||||
defaults?.channels,
|
||||
options.channels
|
||||
) as ConnectionOptions<TChannels>['channels']
|
||||
} as ConnectionOptions<TChannels>;
|
||||
}
|
||||
|
||||
export function createEngineConnections<TConnections extends ConnectionMap = ConnectionMap>(
|
||||
options: EngineConnectionsOptions = {}
|
||||
): EngineConnections<TConnections> {
|
||||
const connections = new Map<string, Connection>();
|
||||
const timers =
|
||||
options.timers ??
|
||||
createEngineTimers({
|
||||
logger: options.logger
|
||||
});
|
||||
const ownsTimers = options.timers === undefined;
|
||||
let disposed = false;
|
||||
|
||||
function ensureLive(method: string): void {
|
||||
if (disposed) throw new ConnDisposedError(disposedErrorMessage(method));
|
||||
}
|
||||
|
||||
function getConnection(name: string, method: string): Connection {
|
||||
ensureLive(method);
|
||||
validateConnectionName(name);
|
||||
const connection = connections.get(name);
|
||||
if (connection === undefined) {
|
||||
throw new ConnConnectionNotFoundError(notFoundErrorMessage(name), name);
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
const engine: EngineConnections<TConnections> = {
|
||||
createConnection<TChannels extends ConnectionChannelMap = ConnectionChannelMap>(
|
||||
name: string,
|
||||
connectionOptions: ConnectionOptions<TChannels>
|
||||
): Connection<TChannels> {
|
||||
ensureLive(CONNECTION_METHOD_CREATE_CONNECTION);
|
||||
validateConnectionName(name);
|
||||
if (connections.has(name)) {
|
||||
throw new ConnConnectionAlreadyExistsError(alreadyExistsErrorMessage(name), name);
|
||||
}
|
||||
const connection = createConnection<TChannels>(
|
||||
name,
|
||||
mergeConnectionOptions<TChannels>(options.defaults, connectionOptions),
|
||||
{
|
||||
logger: options.logger,
|
||||
timers,
|
||||
session: options.session
|
||||
}
|
||||
);
|
||||
connections.set(name, connection as Connection);
|
||||
return connection;
|
||||
},
|
||||
connection<TChannels extends ConnectionChannelMap = ConnectionChannelMap>(
|
||||
name: string
|
||||
): Connection<TChannels> {
|
||||
return getConnection(name, CONNECTION_METHOD_CONNECTION) as Connection<TChannels>;
|
||||
},
|
||||
has(name) {
|
||||
if (disposed) return false;
|
||||
return connections.has(name);
|
||||
},
|
||||
names() {
|
||||
if (disposed) return [];
|
||||
return [...connections.keys()];
|
||||
},
|
||||
openConnection(name) {
|
||||
return getConnection(name, CONNECTION_METHOD_OPEN_CONNECTION).connect();
|
||||
},
|
||||
closeConnection(name, reason) {
|
||||
getConnection(name, CONNECTION_METHOD_CLOSE_CONNECTION).disconnect(reason);
|
||||
},
|
||||
reconnectConnection(name, reason) {
|
||||
return getConnection(name, CONNECTION_METHOD_RECONNECT_CONNECTION).reconnect(reason);
|
||||
},
|
||||
openAll() {
|
||||
ensureLive(CONNECTION_METHOD_OPEN_ALL);
|
||||
return Promise.all([...connections.values()].map((connection) => connection.connect()));
|
||||
},
|
||||
closeAll(reason) {
|
||||
ensureLive(CONNECTION_METHOD_CLOSE_ALL);
|
||||
for (const connection of connections.values()) connection.disconnect(reason);
|
||||
},
|
||||
reconnectAll(reason) {
|
||||
ensureLive(CONNECTION_METHOD_RECONNECT_ALL);
|
||||
return Promise.all(
|
||||
[...connections.values()].map((connection) => connection.reconnect(reason))
|
||||
);
|
||||
},
|
||||
close(name, reason) {
|
||||
this.closeConnection(name, reason);
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
for (const connection of connections.values()) connection.dispose();
|
||||
connections.clear();
|
||||
if (ownsTimers) timers.dispose();
|
||||
}
|
||||
};
|
||||
|
||||
return engine;
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
import {
|
||||
ERROR_NAME_ALREADY_EXISTS,
|
||||
ERROR_NAME_CHANNEL_ALREADY_EXISTS,
|
||||
ERROR_NAME_CHANNEL_NOT_FOUND,
|
||||
ERROR_NAME_DISPOSED,
|
||||
ERROR_NAME_INVALID_FRAME,
|
||||
ERROR_NAME_INVALID_NAME,
|
||||
ERROR_NAME_NOT_FOUND
|
||||
} from './consts.ts';
|
||||
|
||||
abstract class ConnEngineError extends Error {
|
||||
abstract readonly name: string;
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options);
|
||||
}
|
||||
}
|
||||
|
||||
export class ConnDisposedError extends ConnEngineError {
|
||||
readonly name = ERROR_NAME_DISPOSED;
|
||||
}
|
||||
|
||||
export class ConnConnectionAlreadyExistsError extends ConnEngineError {
|
||||
readonly name = ERROR_NAME_ALREADY_EXISTS;
|
||||
readonly connection: string;
|
||||
constructor(message: string, connection: string) {
|
||||
super(message);
|
||||
this.connection = connection;
|
||||
}
|
||||
}
|
||||
|
||||
export class ConnConnectionNotFoundError extends ConnEngineError {
|
||||
readonly name = ERROR_NAME_NOT_FOUND;
|
||||
readonly connection: string;
|
||||
constructor(message: string, connection: string) {
|
||||
super(message);
|
||||
this.connection = connection;
|
||||
}
|
||||
}
|
||||
|
||||
export class ConnInvalidConnectionNameError extends ConnEngineError {
|
||||
readonly name = ERROR_NAME_INVALID_NAME;
|
||||
}
|
||||
|
||||
export class ConnInvalidFrameError extends ConnEngineError {
|
||||
readonly name = ERROR_NAME_INVALID_FRAME;
|
||||
}
|
||||
|
||||
export class ConnChannelAlreadyExistsError extends ConnEngineError {
|
||||
readonly name = ERROR_NAME_CHANNEL_ALREADY_EXISTS;
|
||||
readonly channel: string;
|
||||
constructor(message: string, channel: string) {
|
||||
super(message);
|
||||
this.channel = channel;
|
||||
}
|
||||
}
|
||||
|
||||
export class ConnChannelNotFoundError extends ConnEngineError {
|
||||
readonly name = ERROR_NAME_CHANNEL_NOT_FOUND;
|
||||
readonly channel: string;
|
||||
constructor(message: string, channel: string) {
|
||||
super(message);
|
||||
this.channel = channel;
|
||||
}
|
||||
}
|
||||
|
||||
export function isConnDisposedError(value: unknown): value is ConnDisposedError {
|
||||
return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_DISPOSED;
|
||||
}
|
||||
|
||||
export function isConnConnectionAlreadyExistsError(
|
||||
value: unknown
|
||||
): value is ConnConnectionAlreadyExistsError {
|
||||
return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_ALREADY_EXISTS;
|
||||
}
|
||||
|
||||
export function isConnConnectionNotFoundError(
|
||||
value: unknown
|
||||
): value is ConnConnectionNotFoundError {
|
||||
return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_NOT_FOUND;
|
||||
}
|
||||
|
||||
export function isConnInvalidConnectionNameError(
|
||||
value: unknown
|
||||
): value is ConnInvalidConnectionNameError {
|
||||
return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_INVALID_NAME;
|
||||
}
|
||||
|
||||
export function isConnInvalidFrameError(value: unknown): value is ConnInvalidFrameError {
|
||||
return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_INVALID_FRAME;
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
export { createActiveConnections } from './active-connections.svelte.ts';
|
||||
export { createConnectionChannel } from './channel.ts';
|
||||
export { createConnection } from './connection.ts';
|
||||
export { createEngineConnections } from './engine-connections.ts';
|
||||
export { assertConnectionFrame, createFrame } from './serializer.ts';
|
||||
export { jsonConnectionSerializer } from './serializers/json.ts';
|
||||
export { createMockTransport } from './transports/mock.ts';
|
||||
export { createWebSocketTransport } from './transports/websocket.ts';
|
||||
|
||||
export * from './consts.ts';
|
||||
export * from './errors.ts';
|
||||
export * from './types.ts';
|
||||
@ -0,0 +1,87 @@
|
||||
import {
|
||||
FRAME_KEY_ACK,
|
||||
FRAME_KEY_ERROR,
|
||||
FRAME_KEY_ID,
|
||||
FRAME_KEY_PAYLOAD,
|
||||
FRAME_KEY_REPLY_TO,
|
||||
FRAME_KEY_TOPIC,
|
||||
FRAME_KEY_TS,
|
||||
FRAME_KEY_TYPE,
|
||||
ERROR_MSG_FRAME_ACK_MUST_BE_BOOLEAN,
|
||||
ERROR_MSG_FRAME_ERROR_MUST_BE_OBJECT,
|
||||
ERROR_MSG_FRAME_ID_MUST_BE_STRING,
|
||||
ERROR_MSG_FRAME_MUST_BE_OBJECT,
|
||||
ERROR_MSG_FRAME_REPLY_TO_MUST_BE_STRING,
|
||||
ERROR_MSG_FRAME_TOPIC_MUST_BE_STRING,
|
||||
ERROR_MSG_FRAME_TS_MUST_BE_NUMBER,
|
||||
ERROR_MSG_FRAME_TYPE_MUST_BE_STRING
|
||||
} from './consts.ts';
|
||||
import { ConnInvalidFrameError } from './errors.ts';
|
||||
import type { ConnectionFrame } from './types.ts';
|
||||
|
||||
export function assertConnectionFrame(value: unknown): asserts value is ConnectionFrame {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new ConnInvalidFrameError(ERROR_MSG_FRAME_MUST_BE_OBJECT);
|
||||
}
|
||||
const frame = value as Record<string, unknown>;
|
||||
if (typeof frame[FRAME_KEY_TYPE] !== 'string' || frame[FRAME_KEY_TYPE].length === 0) {
|
||||
throw new ConnInvalidFrameError(ERROR_MSG_FRAME_TYPE_MUST_BE_STRING);
|
||||
}
|
||||
if (
|
||||
FRAME_KEY_ID in frame &&
|
||||
frame[FRAME_KEY_ID] !== undefined &&
|
||||
typeof frame[FRAME_KEY_ID] !== 'string'
|
||||
) {
|
||||
throw new ConnInvalidFrameError(ERROR_MSG_FRAME_ID_MUST_BE_STRING);
|
||||
}
|
||||
if (
|
||||
FRAME_KEY_TOPIC in frame &&
|
||||
frame[FRAME_KEY_TOPIC] !== undefined &&
|
||||
typeof frame[FRAME_KEY_TOPIC] !== 'string'
|
||||
) {
|
||||
throw new ConnInvalidFrameError(ERROR_MSG_FRAME_TOPIC_MUST_BE_STRING);
|
||||
}
|
||||
if (
|
||||
FRAME_KEY_REPLY_TO in frame &&
|
||||
frame[FRAME_KEY_REPLY_TO] !== undefined &&
|
||||
typeof frame[FRAME_KEY_REPLY_TO] !== 'string'
|
||||
) {
|
||||
throw new ConnInvalidFrameError(ERROR_MSG_FRAME_REPLY_TO_MUST_BE_STRING);
|
||||
}
|
||||
if (
|
||||
FRAME_KEY_ACK in frame &&
|
||||
frame[FRAME_KEY_ACK] !== undefined &&
|
||||
typeof frame[FRAME_KEY_ACK] !== 'boolean'
|
||||
) {
|
||||
throw new ConnInvalidFrameError(ERROR_MSG_FRAME_ACK_MUST_BE_BOOLEAN);
|
||||
}
|
||||
if (
|
||||
FRAME_KEY_TS in frame &&
|
||||
frame[FRAME_KEY_TS] !== undefined &&
|
||||
typeof frame[FRAME_KEY_TS] !== 'number'
|
||||
) {
|
||||
throw new ConnInvalidFrameError(ERROR_MSG_FRAME_TS_MUST_BE_NUMBER);
|
||||
}
|
||||
if (
|
||||
FRAME_KEY_ERROR in frame &&
|
||||
frame[FRAME_KEY_ERROR] !== undefined &&
|
||||
(frame[FRAME_KEY_ERROR] === null || typeof frame[FRAME_KEY_ERROR] !== 'object')
|
||||
) {
|
||||
throw new ConnInvalidFrameError(ERROR_MSG_FRAME_ERROR_MUST_BE_OBJECT);
|
||||
}
|
||||
}
|
||||
|
||||
export function createFrame(input: ConnectionFrame): ConnectionFrame {
|
||||
assertConnectionFrame(input);
|
||||
const frame: Record<string, unknown> = {
|
||||
[FRAME_KEY_TYPE]: input.type,
|
||||
[FRAME_KEY_PAYLOAD]: input.payload
|
||||
};
|
||||
if (input.id !== undefined) frame[FRAME_KEY_ID] = input.id;
|
||||
if (input.topic !== undefined) frame[FRAME_KEY_TOPIC] = input.topic;
|
||||
if (input.ts !== undefined) frame[FRAME_KEY_TS] = input.ts;
|
||||
if (input.ack !== undefined) frame[FRAME_KEY_ACK] = input.ack;
|
||||
if (input.replyTo !== undefined) frame[FRAME_KEY_REPLY_TO] = input.replyTo;
|
||||
if (input.error !== undefined) frame[FRAME_KEY_ERROR] = input.error;
|
||||
return frame as unknown as ConnectionFrame;
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import { ERROR_MSG_JSON_DECODE_FAILED } from '../consts.ts';
|
||||
import { ConnInvalidFrameError } from '../errors.ts';
|
||||
import { assertConnectionFrame, createFrame } from '../serializer.ts';
|
||||
import type { ConnectionFrame, ConnectionSerializer } from '../types.ts';
|
||||
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
function rawToText(raw: string | ArrayBuffer): string {
|
||||
return typeof raw === 'string' ? raw : textDecoder.decode(raw);
|
||||
}
|
||||
|
||||
export const jsonConnectionSerializer: ConnectionSerializer<ConnectionFrame> = {
|
||||
encode(frame) {
|
||||
return JSON.stringify(createFrame(frame));
|
||||
},
|
||||
decode(raw) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rawToText(raw));
|
||||
} catch (cause) {
|
||||
throw new ConnInvalidFrameError(ERROR_MSG_JSON_DECODE_FAILED, { cause });
|
||||
}
|
||||
assertConnectionFrame(parsed);
|
||||
return createFrame(parsed);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,345 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
CONNECTION_ACK_REASON_CLOSED,
|
||||
CONNECTION_ACK_REASON_TIMEOUT,
|
||||
CONNECTION_BUFFER_POLICY_BUFFER,
|
||||
CONNECTION_CONNECT_REASON_AUTH_FAILED,
|
||||
CONNECTION_FRAME_TYPE_JOIN,
|
||||
CONNECTION_SEND_REASON_CLOSED,
|
||||
CONNECTION_STATE_CLOSED,
|
||||
CONNECTION_STATE_FAILED,
|
||||
CONNECTION_STATE_OPEN,
|
||||
CONNECTION_STATE_RECONNECTING,
|
||||
SESSION_EVENT_EXPIRED,
|
||||
createEngineConnections,
|
||||
createFrame,
|
||||
createMockTransport
|
||||
} from '../index.ts';
|
||||
import type { ConnectionFrame, MockConnectionTransport } from '../index.ts';
|
||||
|
||||
function latestFrame(transport: MockConnectionTransport): ConnectionFrame {
|
||||
const sent = transport.sentMessages();
|
||||
return JSON.parse(sent[sent.length - 1] as string) as ConnectionFrame;
|
||||
}
|
||||
|
||||
function emitFrame(transport: MockConnectionTransport, frame: ConnectionFrame): void {
|
||||
transport.emitMessage(JSON.stringify(createFrame(frame)));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('Connection lifecycle', () => {
|
||||
it('connects and disconnects through the transport', async () => {
|
||||
const Connections = createEngineConnections();
|
||||
const transport = createMockTransport();
|
||||
const Main = Connections.createConnection('main', {
|
||||
transport,
|
||||
heartbeat: false,
|
||||
reconnect: false
|
||||
});
|
||||
|
||||
const result = await Main.connect();
|
||||
expect(result.ok).toBe(true);
|
||||
expect(Main.state).toBe(CONNECTION_STATE_OPEN);
|
||||
expect(Main.connected).toBe(true);
|
||||
|
||||
Main.disconnect();
|
||||
|
||||
expect(Main.state).toBe(CONNECTION_STATE_CLOSED);
|
||||
expect(Main.connected).toBe(false);
|
||||
|
||||
Connections.dispose();
|
||||
});
|
||||
|
||||
it('does not reconnect after an intentional disconnect', async () => {
|
||||
vi.useFakeTimers();
|
||||
const Connections = createEngineConnections();
|
||||
const transport = createMockTransport();
|
||||
const Main = Connections.createConnection('main', {
|
||||
transport,
|
||||
heartbeat: false,
|
||||
reconnect: { minDelayMs: 1, maxDelayMs: 1, jitterMs: 0 }
|
||||
});
|
||||
|
||||
await Main.connect();
|
||||
Main.disconnect();
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(Main.state).toBe(CONNECTION_STATE_CLOSED);
|
||||
|
||||
Connections.dispose();
|
||||
});
|
||||
|
||||
it('reconnects after an unexpected close', async () => {
|
||||
vi.useFakeTimers();
|
||||
const Connections = createEngineConnections();
|
||||
const transport = createMockTransport();
|
||||
const Main = Connections.createConnection('main', {
|
||||
transport,
|
||||
heartbeat: false,
|
||||
reconnect: { minDelayMs: 1, maxDelayMs: 1, jitterMs: 0 }
|
||||
});
|
||||
|
||||
await Main.connect();
|
||||
const firstGeneration = Main.generation;
|
||||
|
||||
transport.emitClose({ clean: false });
|
||||
expect(Main.state).toBe(CONNECTION_STATE_RECONNECTING);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(Main.state).toBe(CONNECTION_STATE_OPEN);
|
||||
expect(Main.generation).toBe(firstGeneration + 1);
|
||||
|
||||
Connections.dispose();
|
||||
});
|
||||
|
||||
it('closes the transport when connect-time auth fails', async () => {
|
||||
vi.useFakeTimers();
|
||||
const Connections = createEngineConnections();
|
||||
const transport = createMockTransport();
|
||||
const Main = Connections.createConnection('main', {
|
||||
transport,
|
||||
heartbeat: false,
|
||||
reconnect: false,
|
||||
auth: {
|
||||
getAuth: () => ({}),
|
||||
timeoutMs: 5
|
||||
}
|
||||
});
|
||||
|
||||
const pending = Main.connect();
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
const result = await pending;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
reason: CONNECTION_CONNECT_REASON_AUTH_FAILED
|
||||
});
|
||||
expect(Main.state).toBe(CONNECTION_STATE_FAILED);
|
||||
expect(transport.canSend).toBe(false);
|
||||
|
||||
Connections.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Connection send/request', () => {
|
||||
it('sends a serialized frame when open', async () => {
|
||||
const Connections = createEngineConnections();
|
||||
const transport = createMockTransport();
|
||||
const Main = Connections.createConnection('main', {
|
||||
transport,
|
||||
heartbeat: false,
|
||||
reconnect: false
|
||||
});
|
||||
|
||||
await Main.connect();
|
||||
const result = await Main.send('demo.event', { ok: true });
|
||||
const sent = latestFrame(transport);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(sent).toMatchObject({
|
||||
type: 'demo.event',
|
||||
payload: { ok: true }
|
||||
});
|
||||
|
||||
Connections.dispose();
|
||||
});
|
||||
|
||||
it('fails fire-and-forget sends while closed unless buffering is enabled', async () => {
|
||||
const Connections = createEngineConnections();
|
||||
const Main = Connections.createConnection('main', {
|
||||
transport: createMockTransport(),
|
||||
heartbeat: false,
|
||||
reconnect: false
|
||||
});
|
||||
|
||||
const result = await Main.send('demo.event', undefined);
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: CONNECTION_SEND_REASON_CLOSED });
|
||||
|
||||
Connections.dispose();
|
||||
});
|
||||
|
||||
it('buffers fire-and-forget frames and flushes on connect', async () => {
|
||||
const Connections = createEngineConnections();
|
||||
const transport = createMockTransport();
|
||||
const Main = Connections.createConnection('main', {
|
||||
transport,
|
||||
heartbeat: false,
|
||||
reconnect: false,
|
||||
buffer: { policy: CONNECTION_BUFFER_POLICY_BUFFER }
|
||||
});
|
||||
|
||||
const buffered = await Main.send('demo.buffered', { queued: true });
|
||||
expect(buffered.ok).toBe(true);
|
||||
expect(transport.sentMessages()).toHaveLength(0);
|
||||
|
||||
await Main.connect();
|
||||
|
||||
expect(latestFrame(transport)).toMatchObject({
|
||||
type: 'demo.buffered',
|
||||
payload: { queued: true }
|
||||
});
|
||||
|
||||
Connections.dispose();
|
||||
});
|
||||
|
||||
it('resolves a request from an incoming replyTo frame', async () => {
|
||||
const Connections = createEngineConnections();
|
||||
const transport = createMockTransport();
|
||||
const Main = Connections.createConnection('main', {
|
||||
transport,
|
||||
heartbeat: false,
|
||||
reconnect: false
|
||||
});
|
||||
|
||||
await Main.connect();
|
||||
const pending = Main.request<{ ask: boolean }, { answer: boolean }>('demo.request', {
|
||||
ask: true
|
||||
});
|
||||
await Promise.resolve();
|
||||
const sent = latestFrame(transport);
|
||||
|
||||
emitFrame(transport, {
|
||||
type: 'demo.reply',
|
||||
payload: { answer: true },
|
||||
replyTo: sent.id
|
||||
});
|
||||
|
||||
await expect(pending).resolves.toEqual({ ok: true, payload: { answer: true } });
|
||||
|
||||
Connections.dispose();
|
||||
});
|
||||
|
||||
it('times out unanswered requests', async () => {
|
||||
vi.useFakeTimers();
|
||||
const Connections = createEngineConnections();
|
||||
const Main = Connections.createConnection('main', {
|
||||
transport: createMockTransport(),
|
||||
heartbeat: false,
|
||||
reconnect: false
|
||||
});
|
||||
|
||||
await Main.connect();
|
||||
const pending = Main.request('demo.request', undefined, { timeoutMs: 5 });
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
|
||||
await expect(pending).resolves.toEqual({ ok: false, reason: CONNECTION_ACK_REASON_TIMEOUT });
|
||||
|
||||
Connections.dispose();
|
||||
});
|
||||
|
||||
it('closes pending requests on disconnect', async () => {
|
||||
const Connections = createEngineConnections();
|
||||
const Main = Connections.createConnection('main', {
|
||||
transport: createMockTransport(),
|
||||
heartbeat: false,
|
||||
reconnect: false
|
||||
});
|
||||
|
||||
await Main.connect();
|
||||
const pending = Main.request('demo.request', undefined);
|
||||
Main.disconnect();
|
||||
|
||||
await expect(pending).resolves.toEqual({ ok: false, reason: CONNECTION_ACK_REASON_CLOSED });
|
||||
|
||||
Connections.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Connection channels', () => {
|
||||
it('caches channel instances by name', () => {
|
||||
const Connections = createEngineConnections();
|
||||
const Main = Connections.createConnection('main', {
|
||||
transport: createMockTransport(),
|
||||
heartbeat: false,
|
||||
reconnect: false
|
||||
});
|
||||
|
||||
expect(Main.channel('orders')).toBe(Main.channel('orders'));
|
||||
|
||||
Connections.dispose();
|
||||
});
|
||||
|
||||
it('routes topic frames to the matching channel', async () => {
|
||||
const Connections = createEngineConnections();
|
||||
const transport = createMockTransport();
|
||||
const Main = Connections.createConnection('main', {
|
||||
transport,
|
||||
heartbeat: false,
|
||||
reconnect: false
|
||||
});
|
||||
const Orders = Main.channel<{ 'order.updated': { id: string } }>('orders');
|
||||
const calls: string[] = [];
|
||||
|
||||
Orders.on('order.updated', (payload) => {
|
||||
calls.push(payload.id);
|
||||
});
|
||||
|
||||
await Main.connect();
|
||||
emitFrame(transport, {
|
||||
topic: 'orders',
|
||||
type: 'order.updated',
|
||||
payload: { id: 'o1' }
|
||||
});
|
||||
|
||||
expect(calls).toEqual(['o1']);
|
||||
|
||||
Connections.dispose();
|
||||
});
|
||||
|
||||
it('join sends the standard channel join frame', async () => {
|
||||
const Connections = createEngineConnections();
|
||||
const transport = createMockTransport();
|
||||
const Main = Connections.createConnection('main', {
|
||||
transport,
|
||||
heartbeat: false,
|
||||
reconnect: false
|
||||
});
|
||||
const Orders = Main.channel('orders');
|
||||
|
||||
await Main.connect();
|
||||
const result = await Orders.join({ tenant: 'acme' });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(latestFrame(transport)).toMatchObject({
|
||||
topic: 'orders',
|
||||
type: CONNECTION_FRAME_TYPE_JOIN,
|
||||
payload: { tenant: 'acme' }
|
||||
});
|
||||
|
||||
Connections.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Connection session bridge', () => {
|
||||
it('disconnects opted-in connections when session expires', async () => {
|
||||
let listener: ((change: { readonly event: string }) => void) | undefined;
|
||||
const Connections = createEngineConnections({
|
||||
session: {
|
||||
onChange(fn) {
|
||||
listener = fn;
|
||||
return () => {
|
||||
listener = undefined;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
const Main = Connections.createConnection('main', {
|
||||
transport: createMockTransport(),
|
||||
heartbeat: false,
|
||||
reconnect: false,
|
||||
session: { enabled: true }
|
||||
});
|
||||
|
||||
await Main.connect();
|
||||
listener?.({ event: SESSION_EVENT_EXPIRED });
|
||||
|
||||
expect(Main.state).toBe(CONNECTION_STATE_CLOSED);
|
||||
|
||||
Connections.dispose();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,90 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
CONNECTION_STATE_CLOSED,
|
||||
CONNECTION_STATE_OPEN,
|
||||
createEngineConnections,
|
||||
createMockTransport,
|
||||
isConnConnectionAlreadyExistsError,
|
||||
isConnConnectionNotFoundError
|
||||
} from '../index.ts';
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('createEngineConnections', () => {
|
||||
it('creates, retrieves and lists named connections', () => {
|
||||
const Connections = createEngineConnections();
|
||||
const transport = createMockTransport();
|
||||
const Main = Connections.createConnection('main', {
|
||||
transport,
|
||||
heartbeat: false,
|
||||
reconnect: false
|
||||
});
|
||||
|
||||
expect(Connections.has('main')).toBe(true);
|
||||
expect(Connections.names()).toEqual(['main']);
|
||||
expect(Connections.connection('main')).toBe(Main);
|
||||
|
||||
Connections.dispose();
|
||||
});
|
||||
|
||||
it('rejects duplicate names with a typed error', () => {
|
||||
const Connections = createEngineConnections();
|
||||
Connections.createConnection('main', {
|
||||
transport: createMockTransport(),
|
||||
heartbeat: false,
|
||||
reconnect: false
|
||||
});
|
||||
|
||||
try {
|
||||
Connections.createConnection('main', {
|
||||
transport: createMockTransport(),
|
||||
heartbeat: false,
|
||||
reconnect: false
|
||||
});
|
||||
} catch (error) {
|
||||
expect(isConnConnectionAlreadyExistsError(error)).toBe(true);
|
||||
}
|
||||
|
||||
Connections.dispose();
|
||||
});
|
||||
|
||||
it('throws a typed error for missing names', () => {
|
||||
const Connections = createEngineConnections();
|
||||
|
||||
try {
|
||||
Connections.connection('missing');
|
||||
} catch (error) {
|
||||
expect(isConnConnectionNotFoundError(error)).toBe(true);
|
||||
}
|
||||
|
||||
Connections.dispose();
|
||||
});
|
||||
|
||||
it('opens and closes every registered connection', async () => {
|
||||
const Connections = createEngineConnections();
|
||||
Connections.createConnection('main', {
|
||||
transport: createMockTransport(),
|
||||
heartbeat: false,
|
||||
reconnect: false
|
||||
});
|
||||
Connections.createConnection('chat', {
|
||||
transport: createMockTransport(),
|
||||
heartbeat: false,
|
||||
reconnect: false
|
||||
});
|
||||
|
||||
const results = await Connections.openAll();
|
||||
expect(results.every((result) => result.ok)).toBe(true);
|
||||
expect(Connections.connection('main').state).toBe(CONNECTION_STATE_OPEN);
|
||||
expect(Connections.connection('chat').state).toBe(CONNECTION_STATE_OPEN);
|
||||
|
||||
Connections.closeAll();
|
||||
|
||||
expect(Connections.connection('main').state).toBe(CONNECTION_STATE_CLOSED);
|
||||
expect(Connections.connection('chat').state).toBe(CONNECTION_STATE_CLOSED);
|
||||
|
||||
Connections.dispose();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,25 @@
|
||||
export interface TransportEmitter<TListener extends (...args: never[]) => void> {
|
||||
on(listener: TListener): () => void;
|
||||
emit(...args: Parameters<TListener>): void;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
export function createTransportEmitter<
|
||||
TListener extends (...args: never[]) => void
|
||||
>(): TransportEmitter<TListener> {
|
||||
const listeners = new Set<TListener>();
|
||||
return {
|
||||
on(listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
emit(...args) {
|
||||
for (const listener of [...listeners]) listener(...args);
|
||||
},
|
||||
clear() {
|
||||
listeners.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
import {
|
||||
CONNECTION_TRANSPORT_STATE_CLOSED,
|
||||
CONNECTION_TRANSPORT_STATE_CLOSING,
|
||||
CONNECTION_TRANSPORT_STATE_IDLE,
|
||||
CONNECTION_TRANSPORT_STATE_OPEN,
|
||||
CONNECTION_TRANSPORT_STATE_OPENING,
|
||||
MOCK_TRANSPORT_DEFAULT_CLOSE_CLEAN,
|
||||
MOCK_TRANSPORT_DEFAULT_CLOSE_CODE,
|
||||
TRANSPORT_KIND_MOCK
|
||||
} from '../consts.ts';
|
||||
import { createTransportEmitter } from '../transport.ts';
|
||||
import type {
|
||||
ConnectionCloseEvent,
|
||||
ConnectionTransportState,
|
||||
MockConnectionTransport,
|
||||
MockTransportOptions
|
||||
} from '../types.ts';
|
||||
|
||||
export function createMockTransport(options: MockTransportOptions = {}): MockConnectionTransport {
|
||||
const autoOpen = options.autoOpen ?? true;
|
||||
const openEmitter = createTransportEmitter<() => void>();
|
||||
const messageEmitter = createTransportEmitter<(message: string | ArrayBuffer) => void>();
|
||||
const closeEmitter = createTransportEmitter<(event: ConnectionCloseEvent) => void>();
|
||||
const errorEmitter = createTransportEmitter<(error: unknown) => void>();
|
||||
const sent: (string | ArrayBuffer)[] = [];
|
||||
let state: ConnectionTransportState = CONNECTION_TRANSPORT_STATE_IDLE;
|
||||
const openResolvers = new Set<() => void>();
|
||||
|
||||
function resolveOpenWaiters(): void {
|
||||
for (const resolve of openResolvers) resolve();
|
||||
openResolvers.clear();
|
||||
}
|
||||
|
||||
function emitOpen(): void {
|
||||
state = CONNECTION_TRANSPORT_STATE_OPEN;
|
||||
resolveOpenWaiters();
|
||||
openEmitter.emit();
|
||||
}
|
||||
|
||||
function emitClose(event: Partial<ConnectionCloseEvent> = {}): void {
|
||||
state = CONNECTION_TRANSPORT_STATE_CLOSED;
|
||||
resolveOpenWaiters();
|
||||
closeEmitter.emit({
|
||||
code: event.code ?? MOCK_TRANSPORT_DEFAULT_CLOSE_CODE,
|
||||
reason: event.reason,
|
||||
clean: event.clean ?? MOCK_TRANSPORT_DEFAULT_CLOSE_CLEAN
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
kind: TRANSPORT_KIND_MOCK,
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
get bufferedAmount() {
|
||||
return sent.length;
|
||||
},
|
||||
get canSend() {
|
||||
return state === CONNECTION_TRANSPORT_STATE_OPEN;
|
||||
},
|
||||
open() {
|
||||
if (state === CONNECTION_TRANSPORT_STATE_OPEN) return Promise.resolve();
|
||||
state = CONNECTION_TRANSPORT_STATE_OPENING;
|
||||
if (autoOpen) {
|
||||
emitOpen();
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
openResolvers.add(resolve);
|
||||
});
|
||||
},
|
||||
send(data) {
|
||||
sent.push(data);
|
||||
},
|
||||
close(code, reason) {
|
||||
if (state === CONNECTION_TRANSPORT_STATE_CLOSED) return;
|
||||
state = CONNECTION_TRANSPORT_STATE_CLOSING;
|
||||
emitClose({ code, reason });
|
||||
},
|
||||
onOpen: (listener) => openEmitter.on(listener),
|
||||
onMessage: (listener) => messageEmitter.on(listener),
|
||||
onClose: (listener) => closeEmitter.on(listener),
|
||||
onError: (listener) => errorEmitter.on(listener),
|
||||
emitOpen,
|
||||
emitMessage(message) {
|
||||
messageEmitter.emit(message);
|
||||
},
|
||||
emitClose,
|
||||
emitError(error) {
|
||||
errorEmitter.emit(error);
|
||||
},
|
||||
sentMessages() {
|
||||
return [...sent];
|
||||
},
|
||||
clearSent() {
|
||||
sent.length = 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,187 @@
|
||||
import {
|
||||
BROWSER_EVENT_CLOSE,
|
||||
BROWSER_EVENT_ERROR,
|
||||
BROWSER_EVENT_MESSAGE,
|
||||
BROWSER_EVENT_OPEN,
|
||||
CONNECTION_TRANSPORT_STATE_CLOSED,
|
||||
CONNECTION_TRANSPORT_STATE_CLOSING,
|
||||
CONNECTION_TRANSPORT_STATE_IDLE,
|
||||
CONNECTION_TRANSPORT_STATE_OPEN,
|
||||
CONNECTION_TRANSPORT_STATE_OPENING,
|
||||
ERROR_MSG_WEBSOCKET_UNAVAILABLE,
|
||||
TRANSPORT_KIND_WEBSOCKET,
|
||||
WEBSOCKET_BINARY_TYPE_ARRAYBUFFER,
|
||||
WEBSOCKET_CLOSE_CODE_ABNORMAL,
|
||||
WEBSOCKET_CLOSE_CODE_NORMAL,
|
||||
WEBSOCKET_READY_STATE_CLOSED,
|
||||
WEBSOCKET_READY_STATE_CLOSING,
|
||||
WEBSOCKET_READY_STATE_OPEN
|
||||
} from '../consts.ts';
|
||||
import { createTransportEmitter } from '../transport.ts';
|
||||
import type {
|
||||
ConnectionCloseEvent,
|
||||
ConnectionTransport,
|
||||
ConnectionTransportState,
|
||||
WebSocketConstructorLike,
|
||||
WebSocketLike,
|
||||
WebSocketTransportOptions
|
||||
} from '../types.ts';
|
||||
|
||||
type OpenWaiter = {
|
||||
resolve(): void;
|
||||
reject(error: unknown): void;
|
||||
};
|
||||
|
||||
export function createWebSocketTransport(options: WebSocketTransportOptions): ConnectionTransport {
|
||||
const openEmitter = createTransportEmitter<() => void>();
|
||||
const messageEmitter = createTransportEmitter<(message: string | ArrayBuffer) => void>();
|
||||
const closeEmitter = createTransportEmitter<(event: ConnectionCloseEvent) => void>();
|
||||
const errorEmitter = createTransportEmitter<(error: unknown) => void>();
|
||||
|
||||
let socket: WebSocketLike | null = null;
|
||||
let state: ConnectionTransportState = CONNECTION_TRANSPORT_STATE_IDLE;
|
||||
let openWaiter: OpenWaiter | null = null;
|
||||
let detachSocketListeners: (() => void) | null = null;
|
||||
|
||||
function resolveUrl(): string | URL {
|
||||
return typeof options.url === 'function' ? options.url() : options.url;
|
||||
}
|
||||
|
||||
function resolveProtocols(): string | string[] | undefined {
|
||||
const protocols = options.protocols;
|
||||
return typeof protocols === 'function' ? protocols() : protocols;
|
||||
}
|
||||
|
||||
function resolveConstructor(): WebSocketConstructorLike {
|
||||
const ctor =
|
||||
options.WebSocket ??
|
||||
(globalThis as { readonly WebSocket?: WebSocketConstructorLike }).WebSocket;
|
||||
if (ctor === undefined) throw new Error(ERROR_MSG_WEBSOCKET_UNAVAILABLE);
|
||||
return ctor;
|
||||
}
|
||||
|
||||
function settleOpen(error?: unknown): void {
|
||||
const waiter = openWaiter;
|
||||
openWaiter = null;
|
||||
if (waiter === null) return;
|
||||
if (error === undefined) waiter.resolve();
|
||||
else waiter.reject(error);
|
||||
}
|
||||
|
||||
function readNativeState(): ConnectionTransportState {
|
||||
const current = socket;
|
||||
if (current === null) return state;
|
||||
if (current.readyState === WEBSOCKET_READY_STATE_OPEN) return CONNECTION_TRANSPORT_STATE_OPEN;
|
||||
if (current.readyState === WEBSOCKET_READY_STATE_CLOSING) {
|
||||
return CONNECTION_TRANSPORT_STATE_CLOSING;
|
||||
}
|
||||
if (current.readyState === WEBSOCKET_READY_STATE_CLOSED) {
|
||||
return CONNECTION_TRANSPORT_STATE_CLOSED;
|
||||
}
|
||||
return CONNECTION_TRANSPORT_STATE_OPENING;
|
||||
}
|
||||
|
||||
function removeSocketListeners(): void {
|
||||
detachSocketListeners?.();
|
||||
detachSocketListeners = null;
|
||||
}
|
||||
|
||||
function attachSocketListeners(next: WebSocketLike): void {
|
||||
const handleOpen = (): void => {
|
||||
state = CONNECTION_TRANSPORT_STATE_OPEN;
|
||||
settleOpen();
|
||||
openEmitter.emit();
|
||||
};
|
||||
const handleMessage = (event: unknown): void => {
|
||||
const data = (event as { readonly data?: unknown }).data;
|
||||
if (typeof data === 'string' || data instanceof ArrayBuffer) {
|
||||
messageEmitter.emit(data);
|
||||
return;
|
||||
}
|
||||
if (data !== undefined) messageEmitter.emit(String(data));
|
||||
};
|
||||
const handleClose = (event: unknown): void => {
|
||||
state = CONNECTION_TRANSPORT_STATE_CLOSED;
|
||||
const closeEvent = event as {
|
||||
readonly code?: unknown;
|
||||
readonly reason?: unknown;
|
||||
readonly wasClean?: unknown;
|
||||
};
|
||||
const mapped: ConnectionCloseEvent = {
|
||||
code: typeof closeEvent.code === 'number' ? closeEvent.code : WEBSOCKET_CLOSE_CODE_ABNORMAL,
|
||||
reason: typeof closeEvent.reason === 'string' ? closeEvent.reason : undefined,
|
||||
clean: closeEvent.wasClean === true
|
||||
};
|
||||
settleOpen(mapped);
|
||||
closeEmitter.emit(mapped);
|
||||
};
|
||||
const handleError = (event: unknown): void => {
|
||||
errorEmitter.emit(event);
|
||||
settleOpen(event);
|
||||
};
|
||||
next.addEventListener(BROWSER_EVENT_OPEN, handleOpen);
|
||||
next.addEventListener(BROWSER_EVENT_MESSAGE, handleMessage);
|
||||
next.addEventListener(BROWSER_EVENT_CLOSE, handleClose);
|
||||
next.addEventListener(BROWSER_EVENT_ERROR, handleError);
|
||||
detachSocketListeners = () => {
|
||||
next.removeEventListener(BROWSER_EVENT_OPEN, handleOpen);
|
||||
next.removeEventListener(BROWSER_EVENT_MESSAGE, handleMessage);
|
||||
next.removeEventListener(BROWSER_EVENT_CLOSE, handleClose);
|
||||
next.removeEventListener(BROWSER_EVENT_ERROR, handleError);
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: TRANSPORT_KIND_WEBSOCKET,
|
||||
get state() {
|
||||
return readNativeState();
|
||||
},
|
||||
get bufferedAmount() {
|
||||
return socket?.bufferedAmount ?? 0;
|
||||
},
|
||||
get canSend() {
|
||||
return socket?.readyState === WEBSOCKET_READY_STATE_OPEN;
|
||||
},
|
||||
open() {
|
||||
if (socket?.readyState === WEBSOCKET_READY_STATE_OPEN) return Promise.resolve();
|
||||
if (openWaiter !== null) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const previous = openWaiter;
|
||||
openWaiter = {
|
||||
resolve() {
|
||||
previous?.resolve();
|
||||
resolve();
|
||||
},
|
||||
reject(error) {
|
||||
previous?.reject(error);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
removeSocketListeners();
|
||||
state = CONNECTION_TRANSPORT_STATE_OPENING;
|
||||
const Ctor = resolveConstructor();
|
||||
const protocols = resolveProtocols();
|
||||
const next =
|
||||
protocols === undefined ? new Ctor(resolveUrl()) : new Ctor(resolveUrl(), protocols);
|
||||
next.binaryType = WEBSOCKET_BINARY_TYPE_ARRAYBUFFER;
|
||||
socket = next;
|
||||
attachSocketListeners(next);
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
openWaiter = { resolve, reject };
|
||||
});
|
||||
},
|
||||
send(data) {
|
||||
socket?.send(data);
|
||||
},
|
||||
close(code = WEBSOCKET_CLOSE_CODE_NORMAL, reason) {
|
||||
state = CONNECTION_TRANSPORT_STATE_CLOSING;
|
||||
socket?.close(code, reason);
|
||||
},
|
||||
onOpen: (listener) => openEmitter.on(listener),
|
||||
onMessage: (listener) => messageEmitter.on(listener),
|
||||
onClose: (listener) => closeEmitter.on(listener),
|
||||
onError: (listener) => errorEmitter.on(listener)
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,430 @@
|
||||
import type { TimerScheduler } from '$timr';
|
||||
import type {
|
||||
CONNECTION_ACK_REASON_CLOSED,
|
||||
CONNECTION_ACK_REASON_INVALID_REPLY,
|
||||
CONNECTION_ACK_REASON_REJECTED,
|
||||
CONNECTION_ACK_REASON_TIMEOUT,
|
||||
CONNECTION_ACK_REASON_TRANSPORT_ERROR,
|
||||
CONNECTION_AUTH_REASON_CLOSED,
|
||||
CONNECTION_AUTH_REASON_NO_PROVIDER,
|
||||
CONNECTION_AUTH_REASON_REJECTED,
|
||||
CONNECTION_AUTH_REASON_TIMEOUT,
|
||||
CONNECTION_AUTH_REASON_TRANSPORT_ERROR,
|
||||
CONNECTION_BUFFER_POLICY_BUFFER,
|
||||
CONNECTION_BUFFER_POLICY_DROP,
|
||||
CONNECTION_BUFFER_POLICY_FAIL,
|
||||
CONNECTION_CHANNEL_JOIN_REASON_CLOSED,
|
||||
CONNECTION_CHANNEL_JOIN_REASON_REJECTED,
|
||||
CONNECTION_CHANNEL_JOIN_REASON_TRANSPORT_ERROR,
|
||||
CONNECTION_CHANNEL_STATE_FAILED,
|
||||
CONNECTION_CHANNEL_STATE_IDLE,
|
||||
CONNECTION_CHANNEL_STATE_JOINED,
|
||||
CONNECTION_CHANNEL_STATE_JOINING,
|
||||
CONNECTION_CHANNEL_STATE_LEAVING,
|
||||
CONNECTION_CHANNEL_STATE_LEFT,
|
||||
CONNECTION_CONNECT_REASON_AUTH_FAILED,
|
||||
CONNECTION_CONNECT_REASON_DISPOSED,
|
||||
CONNECTION_CONNECT_REASON_RECONNECT_EXHAUSTED,
|
||||
CONNECTION_CONNECT_REASON_TRANSPORT_ERROR,
|
||||
CONNECTION_SEND_REASON_BUFFER_FULL,
|
||||
CONNECTION_SEND_REASON_CLOSED,
|
||||
CONNECTION_SEND_REASON_INVALID_FRAME,
|
||||
CONNECTION_SEND_REASON_SEND_NOT_SUPPORTED,
|
||||
CONNECTION_SEND_REASON_SERIALIZE_FAILED,
|
||||
CONNECTION_SEND_REASON_TRANSPORT_ERROR,
|
||||
CONNECTION_STATES,
|
||||
CONNECTION_TRANSPORT_STATES
|
||||
} from './consts.ts';
|
||||
|
||||
export type ConnectionState = (typeof CONNECTION_STATES)[number];
|
||||
export type ConnectionTransportState = (typeof CONNECTION_TRANSPORT_STATES)[number];
|
||||
|
||||
export type ConnectionChannelState =
|
||||
| typeof CONNECTION_CHANNEL_STATE_IDLE
|
||||
| typeof CONNECTION_CHANNEL_STATE_JOINING
|
||||
| typeof CONNECTION_CHANNEL_STATE_JOINED
|
||||
| typeof CONNECTION_CHANNEL_STATE_LEAVING
|
||||
| typeof CONNECTION_CHANNEL_STATE_LEFT
|
||||
| typeof CONNECTION_CHANNEL_STATE_FAILED;
|
||||
|
||||
export type ConnectionEventMap = Record<string, unknown>;
|
||||
export type ConnectionChannelMap = Record<string, ConnectionEventMap>;
|
||||
export type ConnectionMap = Record<string, ConnectionChannelMap>;
|
||||
|
||||
export interface ConnectionLogger {
|
||||
debug?(category: string, message: string, input?: unknown): void;
|
||||
info?(category: string, message: string, input?: unknown): void;
|
||||
warn?(category: string, message: string, input?: unknown): void;
|
||||
error?(category: string, message: string, input?: unknown): void;
|
||||
}
|
||||
|
||||
export interface ConnectionCloseEvent {
|
||||
readonly code: number;
|
||||
readonly reason?: string;
|
||||
readonly clean: boolean;
|
||||
}
|
||||
|
||||
export interface ConnectionTransport {
|
||||
readonly kind: string;
|
||||
readonly state: ConnectionTransportState;
|
||||
readonly bufferedAmount: number;
|
||||
readonly canSend: boolean;
|
||||
open(): Promise<void>;
|
||||
send(data: string | ArrayBuffer): Promise<void> | void;
|
||||
close(code?: number, reason?: string): void;
|
||||
onOpen(listener: () => void): () => void;
|
||||
onMessage(listener: (message: string | ArrayBuffer) => void): () => void;
|
||||
onClose(listener: (event: ConnectionCloseEvent) => void): () => void;
|
||||
onError(listener: (error: unknown) => void): () => void;
|
||||
}
|
||||
|
||||
export type WebSocketUrlSource = string | URL | (() => string | URL);
|
||||
export type WebSocketProtocolSource = string | string[] | (() => string | string[] | undefined);
|
||||
export type WebSocketEventListener = (event: unknown) => void;
|
||||
|
||||
export interface WebSocketLike {
|
||||
readonly readyState: number;
|
||||
readonly bufferedAmount: number;
|
||||
binaryType: string;
|
||||
send(data: string | ArrayBuffer): void;
|
||||
close(code?: number, reason?: string): void;
|
||||
addEventListener(type: string, listener: WebSocketEventListener): void;
|
||||
removeEventListener(type: string, listener: WebSocketEventListener): void;
|
||||
}
|
||||
|
||||
export interface WebSocketConstructorLike {
|
||||
new (url: string | URL, protocols?: string | string[]): WebSocketLike;
|
||||
}
|
||||
|
||||
export interface WebSocketTransportOptions {
|
||||
readonly url: WebSocketUrlSource;
|
||||
readonly protocols?: WebSocketProtocolSource;
|
||||
readonly WebSocket?: WebSocketConstructorLike;
|
||||
}
|
||||
|
||||
export interface MockTransportOptions {
|
||||
readonly autoOpen?: boolean;
|
||||
}
|
||||
|
||||
export interface MockConnectionTransport extends ConnectionTransport {
|
||||
emitOpen(): void;
|
||||
emitMessage(message: string | ArrayBuffer): void;
|
||||
emitClose(event?: Partial<ConnectionCloseEvent>): void;
|
||||
emitError(error: unknown): void;
|
||||
sentMessages(): readonly (string | ArrayBuffer)[];
|
||||
clearSent(): void;
|
||||
}
|
||||
|
||||
export interface ConnectionSerializer<TFrame = ConnectionFrame> {
|
||||
encode(frame: TFrame): string | ArrayBuffer;
|
||||
decode(raw: string | ArrayBuffer): TFrame;
|
||||
}
|
||||
|
||||
export interface ConnectionFrameError {
|
||||
readonly message: string;
|
||||
readonly code?: string;
|
||||
readonly details?: unknown;
|
||||
}
|
||||
|
||||
export interface ConnectionFrame<TType extends string = string, TPayload = unknown> {
|
||||
readonly id?: string;
|
||||
readonly topic?: string;
|
||||
readonly type: TType;
|
||||
readonly payload: TPayload;
|
||||
readonly ts?: number;
|
||||
readonly ack?: boolean;
|
||||
readonly replyTo?: string;
|
||||
readonly error?: ConnectionFrameError;
|
||||
}
|
||||
|
||||
export interface ConnectionMessageMeta {
|
||||
readonly connection: string;
|
||||
readonly topic?: string;
|
||||
readonly receivedAt: number;
|
||||
readonly generation: number;
|
||||
}
|
||||
|
||||
export interface ConnectionStateChange {
|
||||
readonly connection: string;
|
||||
readonly from: ConnectionState;
|
||||
readonly to: ConnectionState;
|
||||
readonly generation: number;
|
||||
readonly error?: unknown;
|
||||
readonly at: number;
|
||||
}
|
||||
|
||||
export interface ConnectionReconnectOptions {
|
||||
readonly enabled?: boolean;
|
||||
readonly minDelayMs?: number;
|
||||
readonly maxDelayMs?: number;
|
||||
readonly factor?: number;
|
||||
readonly jitterMs?: number;
|
||||
readonly maxAttempts?: number;
|
||||
readonly reconnectOnVisible?: boolean;
|
||||
readonly reconnectOnOnline?: boolean;
|
||||
}
|
||||
|
||||
export interface ConnectionHeartbeatOptions {
|
||||
readonly enabled?: boolean;
|
||||
readonly intervalMs?: number;
|
||||
readonly timeoutMs?: number;
|
||||
readonly pingType?: string;
|
||||
readonly pongType?: string;
|
||||
}
|
||||
|
||||
export interface ConnectionBufferOptions {
|
||||
readonly policy?:
|
||||
| typeof CONNECTION_BUFFER_POLICY_BUFFER
|
||||
| typeof CONNECTION_BUFFER_POLICY_DROP
|
||||
| typeof CONNECTION_BUFFER_POLICY_FAIL;
|
||||
readonly maxMessages?: number;
|
||||
readonly maxBytes?: number;
|
||||
}
|
||||
|
||||
export type ConnectionAuthPayload = Record<string, unknown>;
|
||||
|
||||
export interface ConnectionAuthOptions {
|
||||
readonly getAuth?: () => ConnectionAuthPayload | null | Promise<ConnectionAuthPayload | null>;
|
||||
readonly authType?: string;
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface ConnectionSessionOptions {
|
||||
readonly enabled?: boolean;
|
||||
readonly reauthOnRefresh?: boolean;
|
||||
readonly disconnectOnExpire?: boolean;
|
||||
}
|
||||
|
||||
export interface ConnectionChannelOptions {
|
||||
readonly autoJoin?: boolean;
|
||||
readonly rejoinOnReconnect?: boolean;
|
||||
readonly params?: () => unknown | Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface ConnectionOptions<TChannels extends ConnectionChannelMap = ConnectionChannelMap> {
|
||||
readonly transport: ConnectionTransport | (() => ConnectionTransport);
|
||||
readonly serializer?: ConnectionSerializer;
|
||||
readonly reconnect?: ConnectionReconnectOptions | false;
|
||||
readonly heartbeat?: ConnectionHeartbeatOptions | false;
|
||||
readonly buffer?: ConnectionBufferOptions;
|
||||
readonly auth?:
|
||||
| ConnectionAuthOptions
|
||||
| (() => ConnectionAuthPayload | null | Promise<ConnectionAuthPayload | null>);
|
||||
readonly session?: false | ConnectionSessionOptions;
|
||||
readonly channels?: {
|
||||
readonly [K in keyof TChannels & string]?: ConnectionChannelOptions;
|
||||
};
|
||||
readonly loggerScope?: string;
|
||||
}
|
||||
|
||||
export interface ConnectionSendOptions {
|
||||
readonly id?: string;
|
||||
readonly topic?: string;
|
||||
readonly ack?: boolean;
|
||||
readonly ts?: number;
|
||||
readonly buffer?: boolean;
|
||||
}
|
||||
|
||||
export interface ConnectionRequestOptions extends ConnectionSendOptions {
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
export type ConnectionSendResult =
|
||||
| { readonly ok: true; readonly id?: string }
|
||||
| {
|
||||
readonly ok: false;
|
||||
readonly reason:
|
||||
| typeof CONNECTION_SEND_REASON_CLOSED
|
||||
| typeof CONNECTION_SEND_REASON_SEND_NOT_SUPPORTED
|
||||
| typeof CONNECTION_SEND_REASON_BUFFER_FULL
|
||||
| typeof CONNECTION_SEND_REASON_INVALID_FRAME
|
||||
| typeof CONNECTION_SEND_REASON_SERIALIZE_FAILED
|
||||
| typeof CONNECTION_SEND_REASON_TRANSPORT_ERROR;
|
||||
readonly error?: unknown;
|
||||
};
|
||||
|
||||
export type ConnectionAckResult<T = unknown> =
|
||||
| { readonly ok: true; readonly payload: T }
|
||||
| {
|
||||
readonly ok: false;
|
||||
readonly reason:
|
||||
| typeof CONNECTION_ACK_REASON_TIMEOUT
|
||||
| typeof CONNECTION_ACK_REASON_CLOSED
|
||||
| typeof CONNECTION_ACK_REASON_REJECTED
|
||||
| typeof CONNECTION_ACK_REASON_TRANSPORT_ERROR
|
||||
| typeof CONNECTION_ACK_REASON_INVALID_REPLY;
|
||||
readonly error?: unknown;
|
||||
};
|
||||
|
||||
export type ConnectionAuthResult =
|
||||
| { readonly ok: true }
|
||||
| {
|
||||
readonly ok: false;
|
||||
readonly reason:
|
||||
| typeof CONNECTION_AUTH_REASON_NO_PROVIDER
|
||||
| typeof CONNECTION_AUTH_REASON_CLOSED
|
||||
| typeof CONNECTION_AUTH_REASON_REJECTED
|
||||
| typeof CONNECTION_AUTH_REASON_TIMEOUT
|
||||
| typeof CONNECTION_AUTH_REASON_TRANSPORT_ERROR;
|
||||
readonly error?: unknown;
|
||||
};
|
||||
|
||||
export type ConnectionConnectResult =
|
||||
| { readonly ok: true; readonly state: ConnectionState; readonly reused?: boolean }
|
||||
| {
|
||||
readonly ok: false;
|
||||
readonly state: ConnectionState;
|
||||
readonly reason:
|
||||
| typeof CONNECTION_CONNECT_REASON_TRANSPORT_ERROR
|
||||
| typeof CONNECTION_CONNECT_REASON_AUTH_FAILED
|
||||
| typeof CONNECTION_CONNECT_REASON_DISPOSED
|
||||
| typeof CONNECTION_CONNECT_REASON_RECONNECT_EXHAUSTED;
|
||||
readonly error?: unknown;
|
||||
};
|
||||
|
||||
export type ConnectionChannelJoinResult =
|
||||
| { readonly ok: true }
|
||||
| {
|
||||
readonly ok: false;
|
||||
readonly reason:
|
||||
| typeof CONNECTION_CHANNEL_JOIN_REASON_CLOSED
|
||||
| typeof CONNECTION_CHANNEL_JOIN_REASON_REJECTED
|
||||
| typeof CONNECTION_CHANNEL_JOIN_REASON_TRANSPORT_ERROR;
|
||||
readonly error?: unknown;
|
||||
};
|
||||
|
||||
export interface ConnectionChannel<TEvents extends ConnectionEventMap = ConnectionEventMap> {
|
||||
readonly name: string;
|
||||
readonly state: ConnectionChannelState;
|
||||
join(params?: unknown): Promise<ConnectionChannelJoinResult>;
|
||||
leave(): Promise<void>;
|
||||
send<K extends keyof TEvents & string>(
|
||||
type: K,
|
||||
payload: TEvents[K],
|
||||
options?: ConnectionSendOptions
|
||||
): Promise<ConnectionSendResult>;
|
||||
request<K extends keyof TEvents & string, TResult = unknown>(
|
||||
type: K,
|
||||
payload: TEvents[K],
|
||||
options?: ConnectionRequestOptions
|
||||
): Promise<ConnectionAckResult<TResult>>;
|
||||
on<K extends keyof TEvents & string>(
|
||||
type: K,
|
||||
listener: (payload: TEvents[K], meta: ConnectionMessageMeta) => void
|
||||
): () => void;
|
||||
onAny(listener: (frame: ConnectionFrame, meta: ConnectionMessageMeta) => void): () => void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface Connection<TChannels extends ConnectionChannelMap = ConnectionChannelMap> {
|
||||
readonly name: string;
|
||||
readonly state: ConnectionState;
|
||||
readonly connected: boolean;
|
||||
readonly generation: number;
|
||||
readonly error: unknown | null;
|
||||
readonly openedAt: number | null;
|
||||
readonly closedAt: number | null;
|
||||
readonly lastMessageAt: number | null;
|
||||
readonly reconnectAttempt: number;
|
||||
connect(): Promise<ConnectionConnectResult>;
|
||||
disconnect(reason?: string): void;
|
||||
reconnect(reason?: string): Promise<ConnectionConnectResult>;
|
||||
reauthenticate(): Promise<ConnectionAuthResult>;
|
||||
send<TPayload>(
|
||||
type: string,
|
||||
payload: TPayload,
|
||||
options?: ConnectionSendOptions
|
||||
): Promise<ConnectionSendResult>;
|
||||
request<TPayload, TResult = unknown>(
|
||||
type: string,
|
||||
payload: TPayload,
|
||||
options?: ConnectionRequestOptions
|
||||
): Promise<ConnectionAckResult<TResult>>;
|
||||
channel<K extends keyof TChannels & string>(
|
||||
name: K,
|
||||
options?: ConnectionChannelOptions
|
||||
): ConnectionChannel<TChannels[K]>;
|
||||
channel<TEvents extends ConnectionEventMap = ConnectionEventMap>(
|
||||
name: string,
|
||||
options?: ConnectionChannelOptions
|
||||
): ConnectionChannel<TEvents>;
|
||||
channels(): readonly ConnectionChannel[];
|
||||
hasChannel(name: string): boolean;
|
||||
leaveChannel(name: string): Promise<void>;
|
||||
onState(listener: (change: ConnectionStateChange) => void): () => void;
|
||||
onAny(listener: (frame: ConnectionFrame, meta: ConnectionMessageMeta) => void): () => void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface EngineConnectionsOptions {
|
||||
readonly logger?: ConnectionLogger;
|
||||
readonly timers?: TimerScheduler;
|
||||
readonly defaults?: Partial<ConnectionOptions>;
|
||||
readonly session?: ConnectionSessionSource;
|
||||
}
|
||||
|
||||
export type ActiveConnectionsOptions = EngineConnectionsOptions;
|
||||
|
||||
export interface ConnectionSessionChange {
|
||||
readonly event: string;
|
||||
}
|
||||
|
||||
export interface ConnectionSessionSource {
|
||||
onChange(listener: (change: ConnectionSessionChange) => void): () => void;
|
||||
}
|
||||
|
||||
export interface EngineConnections<TConnections extends ConnectionMap = ConnectionMap> {
|
||||
createConnection<K extends keyof TConnections & string>(
|
||||
name: K,
|
||||
options: ConnectionOptions<TConnections[K]>
|
||||
): Connection<TConnections[K]>;
|
||||
createConnection<TChannels extends ConnectionChannelMap = ConnectionChannelMap>(
|
||||
name: string,
|
||||
options: ConnectionOptions<TChannels>
|
||||
): Connection<TChannels>;
|
||||
connection<K extends keyof TConnections & string>(name: K): Connection<TConnections[K]>;
|
||||
connection<TChannels extends ConnectionChannelMap = ConnectionChannelMap>(
|
||||
name: string
|
||||
): Connection<TChannels>;
|
||||
has(name: string): boolean;
|
||||
names(): readonly string[];
|
||||
openConnection(name: string): Promise<ConnectionConnectResult>;
|
||||
closeConnection(name: string, reason?: string): void;
|
||||
reconnectConnection(name: string, reason?: string): Promise<ConnectionConnectResult>;
|
||||
openAll(): Promise<readonly ConnectionConnectResult[]>;
|
||||
closeAll(reason?: string): void;
|
||||
reconnectAll(reason?: string): Promise<readonly ConnectionConnectResult[]>;
|
||||
close(name: string, reason?: string): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface ActiveConnections<TConnections extends ConnectionMap = ConnectionMap> extends Omit<
|
||||
EngineConnections<TConnections>,
|
||||
'createConnection' | 'connection'
|
||||
> {
|
||||
readonly size: number;
|
||||
readonly activeNames: readonly string[];
|
||||
readonly states: Readonly<Record<string, ConnectionState>>;
|
||||
readonly connectedNames: readonly string[];
|
||||
readonly connectingNames: readonly string[];
|
||||
readonly reconnectingNames: readonly string[];
|
||||
readonly failedNames: readonly string[];
|
||||
readonly closedNames: readonly string[];
|
||||
readonly allConnected: boolean;
|
||||
readonly anyConnected: boolean;
|
||||
readonly anyConnecting: boolean;
|
||||
readonly anyReconnecting: boolean;
|
||||
readonly anyFailed: boolean;
|
||||
createConnection<K extends keyof TConnections & string>(
|
||||
name: K,
|
||||
options: ConnectionOptions<TConnections[K]>
|
||||
): Connection<TConnections[K]>;
|
||||
createConnection<TChannels extends ConnectionChannelMap = ConnectionChannelMap>(
|
||||
name: string,
|
||||
options: ConnectionOptions<TChannels>
|
||||
): Connection<TChannels>;
|
||||
connection<K extends keyof TConnections & string>(name: K): Connection<TConnections[K]>;
|
||||
connection<TChannels extends ConnectionChannelMap = ConnectionChannelMap>(
|
||||
name: string
|
||||
): Connection<TChannels>;
|
||||
}
|
||||
@ -1,129 +1,8 @@
|
||||
import { NULL_BODY_STATUSES } from './consts.ts';
|
||||
import type { HttpBodyInit, HttpHeadersHook, HttpHeadersInit } from './types.ts';
|
||||
|
||||
const JSON_CONTENT_TYPE_PATTERN = /\/(?:.*[.+-])?json(?:;|$)/i;
|
||||
|
||||
/**
|
||||
* Detect whether the value is "plain enough" for the engine to JSON-stringify
|
||||
* it automatically. Rejects native `BodyInit` types (FormData, Blob, etc.)
|
||||
* because they should be passed through to `fetch` verbatim.
|
||||
*
|
||||
* Adapted from `ofetch`'s `isJSONSerializable` heuristic.
|
||||
*/
|
||||
export function isJSONSerializable(value: unknown): value is Record<string, unknown> | unknown[] {
|
||||
if (value === null || value === undefined) return false;
|
||||
if (typeof value !== 'object') return false;
|
||||
if (Array.isArray(value)) return true;
|
||||
|
||||
const obj = value as { buffer?: unknown; constructor?: { name?: string }; toJSON?: unknown };
|
||||
|
||||
// Typed arrays (Uint8Array, etc.) — their `.buffer` is the ArrayBuffer.
|
||||
if (obj.buffer !== undefined && obj.buffer instanceof ArrayBuffer) return false;
|
||||
// Native `BodyInit` instances we want to pass through.
|
||||
if (
|
||||
value instanceof FormData ||
|
||||
value instanceof URLSearchParams ||
|
||||
value instanceof Blob ||
|
||||
value instanceof ArrayBuffer ||
|
||||
(typeof ReadableStream !== 'undefined' && value instanceof ReadableStream)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Plain object literals or objects with a `toJSON` method.
|
||||
const ctor = obj.constructor?.name;
|
||||
if (ctor === 'Object' || ctor === undefined) return true;
|
||||
if (typeof obj.toJSON === 'function') return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a body for `fetch`. Returns the canonical body and (when the
|
||||
* engine should auto-set it) the inferred `Content-Type`. Returns
|
||||
* `{ body: undefined }` for `null`/`undefined` so callers can spread safely.
|
||||
*/
|
||||
export function serializeBody(body: HttpBodyInit): { body: BodyInit | undefined; contentType?: string } {
|
||||
if (body === null || body === undefined) return { body: undefined };
|
||||
|
||||
if (isJSONSerializable(body)) {
|
||||
return { body: JSON.stringify(body), contentType: 'application/json' };
|
||||
}
|
||||
|
||||
// Native BodyInit — pass through. We do not set Content-Type for
|
||||
// FormData / URLSearchParams so the browser regenerates the multipart
|
||||
// boundary or sets the form-encoded type itself.
|
||||
return { body: body as BodyInit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a Response body, returning the most informative shape:
|
||||
* - `undefined` when the status carries no body or the method is HEAD
|
||||
* - parsed JSON when the response declares a JSON-shaped Content-Type
|
||||
* - text otherwise
|
||||
*
|
||||
* Errors during body read resolve to `undefined` — callers that need raw
|
||||
* access reach for `response.body` directly.
|
||||
*/
|
||||
export async function parseBody(response: Response, method: string): Promise<unknown> {
|
||||
if (NULL_BODY_STATUSES.has(response.status)) return undefined;
|
||||
if (method.toUpperCase() === 'HEAD') return undefined;
|
||||
|
||||
const ct = response.headers.get('content-type') ?? '';
|
||||
|
||||
if (JSON_CONTENT_TYPE_PATTERN.test(ct)) {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await response.text();
|
||||
return text === '' ? undefined : text;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge engine-default headers with per-call headers. Per-call wins on
|
||||
* conflict. Hooks resolve eagerly (so `await` is honored before the request
|
||||
* leaves the engine). Returns a fresh `Headers` instance so callers cannot
|
||||
* mutate the engine's default reference.
|
||||
*/
|
||||
export async function mergeHeaders(
|
||||
defaults: HttpHeadersInit | HttpHeadersHook | undefined,
|
||||
override: HttpHeadersInit | HttpHeadersHook | undefined
|
||||
): Promise<Headers> {
|
||||
const result = new Headers();
|
||||
|
||||
if (defaults !== undefined) await applyHeaders(result, defaults);
|
||||
if (override !== undefined) await applyHeaders(result, override);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a single header source onto an existing `Headers` instance. Exported
|
||||
* so the engine can compose multiple sources from `with()` chains without
|
||||
* losing the parent layer.
|
||||
*/
|
||||
export async function applyHeaders(target: Headers, source: HttpHeadersInit | HttpHeadersHook): Promise<void> {
|
||||
const resolved = typeof source === 'function' ? await source() : source;
|
||||
|
||||
if (resolved instanceof Headers) {
|
||||
resolved.forEach((value, key) => target.set(key, value));
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(resolved)) {
|
||||
for (const [key, value] of resolved) target.set(key, value);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(resolved)) {
|
||||
target.set(key, value);
|
||||
}
|
||||
}
|
||||
export {
|
||||
applyHeaders,
|
||||
isJSONSerializable,
|
||||
isJsonContentType,
|
||||
mergeHeaders,
|
||||
parseBody,
|
||||
serializeBody
|
||||
} from '$libs/http';
|
||||
|
||||
@ -1,59 +1 @@
|
||||
import type { HttpSearchInit } from './types.ts';
|
||||
|
||||
/**
|
||||
* Normalize any supported search input into a `URLSearchParams`. `null` and
|
||||
* `undefined` values are stripped — callers that need an empty query value
|
||||
* pass an explicit empty string.
|
||||
*/
|
||||
export function normalizeSearch(input: HttpSearchInit | undefined): URLSearchParams | undefined {
|
||||
if (input === undefined) return undefined;
|
||||
|
||||
if (input instanceof URLSearchParams) return new URLSearchParams(input);
|
||||
|
||||
if (typeof input === 'string') {
|
||||
return new URLSearchParams(input.startsWith('?') ? input.slice(1) : input);
|
||||
}
|
||||
|
||||
const out = new URLSearchParams();
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
for (const [key, value] of input) {
|
||||
out.append(key, String(value));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (value === null || value === undefined) continue;
|
||||
out.append(key, String(value));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append `params` to the existing query string of `url`. If both sides
|
||||
* declare the same key, both are kept (multi-value query semantics).
|
||||
*/
|
||||
export function appendSearch(url: string, params: URLSearchParams | undefined): string {
|
||||
if (params === undefined || params.size === 0) return url;
|
||||
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
return `${url}${sep}${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a URL against an optional `baseUrl`. Behavior:
|
||||
* - `url` is absolute (`http(s)://...`, `data:`, `blob:`, ...) → returned as-is
|
||||
* - `baseUrl` is empty/undefined → `url` returned as-is (relative)
|
||||
* - both supplied → `baseUrl` and `url` are joined with exactly one `/` between
|
||||
* and any trailing query/hash on `baseUrl` is stripped first (rare, but
|
||||
* defensive)
|
||||
*/
|
||||
export function resolveUrl(url: string, baseUrl: string | undefined): string {
|
||||
if (baseUrl === undefined || baseUrl === '') return url;
|
||||
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(url)) return url;
|
||||
|
||||
const trimmedBase = baseUrl.split('?')[0].split('#')[0].replace(/\/+$/, '');
|
||||
const trimmedUrl = url.replace(/^\/+/, '');
|
||||
return `${trimmedBase}/${trimmedUrl}`;
|
||||
}
|
||||
export { appendSearch, normalizeSearch, resolveUrl } from '$libs/http';
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { ResourceRef } from '$libs/perm';
|
||||
import { getPermissionsContext } from './context.ts';
|
||||
|
||||
interface Props {
|
||||
action: string;
|
||||
resource?: ResourceRef;
|
||||
context?: Record<string, unknown>;
|
||||
children?: Snippet;
|
||||
fallback?: Snippet;
|
||||
loading?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
action,
|
||||
resource = undefined,
|
||||
context = undefined,
|
||||
children,
|
||||
fallback,
|
||||
loading
|
||||
}: Props = $props();
|
||||
|
||||
const permissions = getPermissionsContext();
|
||||
let allowed = $state(false);
|
||||
let pending = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
let cancelled = false;
|
||||
pending = true;
|
||||
void permissions
|
||||
.can({ action, resource, context })
|
||||
.then((next) => {
|
||||
if (cancelled) return;
|
||||
allowed = next;
|
||||
pending = false;
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
allowed = false;
|
||||
pending = false;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if allowed}
|
||||
{@render children?.()}
|
||||
{:else if pending && loading}
|
||||
{@render loading()}
|
||||
{:else}
|
||||
{@render fallback?.()}
|
||||
{/if}
|
||||
@ -0,0 +1,941 @@
|
||||
# perm
|
||||
|
||||
`perm` is the authorization artifact of the framework.
|
||||
|
||||
It is not an RBAC helper. It is a typed authorization runtime built around explicit
|
||||
decisions:
|
||||
|
||||
```ts
|
||||
actor + action + resource + context -> decision
|
||||
```
|
||||
|
||||
The most important rule is:
|
||||
|
||||
> The server decides. The client reflects.
|
||||
|
||||
Use `createEnginePermissions()` in the authoritative runtime: server routes, server actions,
|
||||
API handlers, command handlers, job processors.
|
||||
|
||||
Use `createActivePermissions()` in Svelte/UI code only to improve UX: hide buttons, show
|
||||
disabled states, hydrate snapshots, cache remote checks and render `<Can />`.
|
||||
|
||||
Client-side authorization is never a security boundary.
|
||||
|
||||
## What This Solves
|
||||
|
||||
Most permission systems collapse too early into one of these shapes:
|
||||
|
||||
- RBAC: `user has role admin`.
|
||||
- ABAC: `user.department === resource.department`.
|
||||
- ReBAC: `user is owner/member/viewer of resource`.
|
||||
- UI-only helpers: `can('edit', post)`.
|
||||
|
||||
Real applications need all of them, often in the same decision.
|
||||
|
||||
`perm` models authorization as a policy runtime:
|
||||
|
||||
- Roles are actor attributes.
|
||||
- Ownership and membership are relations.
|
||||
- Request/session/risk data lives in context.
|
||||
- Policies return rich decisions, not booleans.
|
||||
- Deny overrides allow.
|
||||
- Unknown deny fails closed.
|
||||
- Decisions can be explained.
|
||||
- List queries can be filtered or compiled into query plans.
|
||||
|
||||
## Public Surface
|
||||
|
||||
```ts
|
||||
import {
|
||||
createEnginePermissions,
|
||||
createActivePermissions,
|
||||
createPermissionHttpHandlers,
|
||||
definePermSchema,
|
||||
definePolicies,
|
||||
allow,
|
||||
deny,
|
||||
attr,
|
||||
actor,
|
||||
resource,
|
||||
ctx,
|
||||
rel,
|
||||
and,
|
||||
or,
|
||||
not,
|
||||
mask,
|
||||
redact,
|
||||
audit,
|
||||
requireMfa,
|
||||
createSqlCompiler
|
||||
} from '$perm';
|
||||
```
|
||||
|
||||
Main APIs:
|
||||
|
||||
- `createEnginePermissions(options)` creates the authoritative engine.
|
||||
- `createActivePermissions(options)` creates a reactive client-side reflector.
|
||||
- `App.createActivePermissions(options)` creates an App-wired active client with `App.Http` and `App.Logger`.
|
||||
- `createPermissionHttpHandlers(engine, resolveActor)` exposes `check`, `batch`, `what`, `explain`.
|
||||
- `<Can />` renders UI based on `Permissions.can(...)`.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Actor
|
||||
|
||||
The subject asking for access.
|
||||
|
||||
```ts
|
||||
const actor = {
|
||||
type: 'user',
|
||||
id: 'u1',
|
||||
status: 'active',
|
||||
role: 'admin',
|
||||
teamIds: ['team-a']
|
||||
};
|
||||
```
|
||||
|
||||
Required fields:
|
||||
|
||||
- `type`: actor kind, usually `user`, `service`, `token`, `anonymous`.
|
||||
- `id`: stable identifier.
|
||||
|
||||
Everything else is an attribute.
|
||||
|
||||
### Action
|
||||
|
||||
A string in the form `resource.action`.
|
||||
|
||||
```ts
|
||||
'post.read';
|
||||
'post.update';
|
||||
'invoice.approve';
|
||||
'project.member.invite';
|
||||
```
|
||||
|
||||
Wildcards are supported in policies:
|
||||
|
||||
```ts
|
||||
deny('post.*');
|
||||
```
|
||||
|
||||
Use constants in application code if the action is shared across modules.
|
||||
|
||||
### Resource
|
||||
|
||||
The thing being accessed.
|
||||
|
||||
```ts
|
||||
const post = {
|
||||
type: 'post',
|
||||
id: 'p1',
|
||||
visibility: 'private',
|
||||
status: 'draft',
|
||||
ownerId: 'u1',
|
||||
teamId: 'team-a'
|
||||
};
|
||||
```
|
||||
|
||||
Required field:
|
||||
|
||||
- `type`: resource kind.
|
||||
|
||||
Recommended field:
|
||||
|
||||
- `id`: stable identifier.
|
||||
|
||||
Everything else is an attribute.
|
||||
|
||||
### Context
|
||||
|
||||
Per-decision data that is neither actor nor resource.
|
||||
|
||||
```ts
|
||||
const context = {
|
||||
risk: { mfa: true },
|
||||
request: { ip: '127.0.0.1' },
|
||||
tenant: 'acme'
|
||||
};
|
||||
```
|
||||
|
||||
Typical context values:
|
||||
|
||||
- MFA or risk flags.
|
||||
- Tenant id.
|
||||
- Request metadata.
|
||||
- Environment.
|
||||
- Time window.
|
||||
- Feature flag snapshot.
|
||||
|
||||
## Decisions
|
||||
|
||||
`perm` does not return plain booleans from the engine. `check()` returns a decision:
|
||||
|
||||
```ts
|
||||
type PermissionDecision =
|
||||
| {
|
||||
effect: 'allow';
|
||||
policy: string;
|
||||
reason?: string;
|
||||
obligations?: ObligationIR[];
|
||||
advice?: AdviceIR[];
|
||||
ttl?: number;
|
||||
}
|
||||
| { effect: 'deny'; policy?: string; code: string; reason: string; advice?: AdviceIR[] }
|
||||
| {
|
||||
effect: 'indeterminate';
|
||||
reason: string;
|
||||
fallback: 'deny' | 'allow';
|
||||
policy?: string;
|
||||
errors?: unknown[];
|
||||
}
|
||||
| { effect: 'not_applicable'; reason?: string };
|
||||
```
|
||||
|
||||
Decision meaning:
|
||||
|
||||
- `allow`: access is granted.
|
||||
- `deny`: access is explicitly denied.
|
||||
- `indeterminate`: the runtime could not safely decide.
|
||||
- `not_applicable`: no policy matched.
|
||||
|
||||
`can()` is sugar over `check()`:
|
||||
|
||||
```ts
|
||||
await Permissions.can(input); // true only when effect === 'allow'
|
||||
```
|
||||
|
||||
Everything else is false.
|
||||
|
||||
## Conflict Rules
|
||||
|
||||
The combiner is deny-overrides:
|
||||
|
||||
1. A matching `deny` with `true` condition wins.
|
||||
2. A matching `deny` with `unknown` or `error` returns `indeterminate` with fallback `deny`.
|
||||
3. A matching `allow` with `true` condition allows.
|
||||
4. A matching `allow` with `unknown` or `error` returns `indeterminate`.
|
||||
5. No matching policy returns `not_applicable`.
|
||||
|
||||
The important safety rule:
|
||||
|
||||
> If a deny policy might apply but cannot be evaluated safely, access is not allowed.
|
||||
|
||||
This avoids the classic production bug: "the membership provider failed, therefore the allow
|
||||
policy won".
|
||||
|
||||
## Schema
|
||||
|
||||
The schema declares actors, resources, actions, attributes and relations.
|
||||
|
||||
```ts
|
||||
const schema = definePermSchema({
|
||||
actors: {
|
||||
user: {
|
||||
attributes: {
|
||||
status: 'string',
|
||||
role: 'string',
|
||||
teamIds: 'string[]'
|
||||
}
|
||||
}
|
||||
},
|
||||
resources: {
|
||||
post: {
|
||||
actions: ['read', 'update', 'publish', 'delete'],
|
||||
attributes: {
|
||||
visibility: 'string',
|
||||
status: 'string',
|
||||
ownerId: 'string',
|
||||
teamId: 'string'
|
||||
}
|
||||
}
|
||||
},
|
||||
relations: {
|
||||
'post.owner': { from: 'post', to: 'user' },
|
||||
'post.team.member': { from: 'post', to: 'user' },
|
||||
'post.team.admin': { from: 'post', to: 'user' }
|
||||
},
|
||||
context: {
|
||||
risk: { mfa: 'boolean' }
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
The schema is intentionally lightweight. It is not a database schema and it is not a validation
|
||||
schema. It is the authorization vocabulary.
|
||||
|
||||
## Policies
|
||||
|
||||
Policies are built as data. The fluent builder creates an IR that can be evaluated, explained,
|
||||
serialized and partially compiled.
|
||||
|
||||
```ts
|
||||
const policies = definePolicies(schema, [
|
||||
deny('post.*')
|
||||
.id('post.deny.suspended')
|
||||
.priority(1000)
|
||||
.when(attr('actor.status').eq('suspended'))
|
||||
.because('Suspended users cannot access posts', 'user_suspended'),
|
||||
|
||||
allow('post.read')
|
||||
.id('post.read.public-or-member')
|
||||
.when(or(attr('post.visibility').eq('public'), rel('post.team.member').is(actor())))
|
||||
.oblige(mask('internalNotes')),
|
||||
|
||||
allow('post.update')
|
||||
.id('post.update.owner')
|
||||
.when(and(rel('post.owner').is(actor()), attr('post.status').notEq('archived'))),
|
||||
|
||||
allow('post.publish')
|
||||
.id('post.publish.admin-with-mfa')
|
||||
.when(and(rel('post.team.admin').is(actor()), attr('context.risk.mfa').eq(true)))
|
||||
.oblige(audit('post.publish', 'medium'))
|
||||
]);
|
||||
```
|
||||
|
||||
Policy methods:
|
||||
|
||||
- `.id(id)` gives the policy a stable id. Always use this in real code.
|
||||
- `.priority(number)` resolves conflicts inside same effect class.
|
||||
- `.when(expr)` sets the condition.
|
||||
- `.because(reason, code?)` attaches denial/explanation metadata.
|
||||
- `.oblige(...items)` attaches obligations to an allow.
|
||||
- `.advise(...items)` attaches non-mandatory advice.
|
||||
- `.meta(record)` stores free-form metadata.
|
||||
|
||||
## Expressions
|
||||
|
||||
Available expression helpers:
|
||||
|
||||
```ts
|
||||
attr('actor.status').eq('active');
|
||||
attr('post.visibility').eq('public');
|
||||
attr('post.status').notEq('archived');
|
||||
attr('context.risk.mfa').eq(true);
|
||||
|
||||
rel('post.owner').is(actor());
|
||||
rel('post.team.admin').has(actor());
|
||||
|
||||
and(exprA, exprB);
|
||||
or(exprA, exprB);
|
||||
not(expr);
|
||||
```
|
||||
|
||||
Reference helpers:
|
||||
|
||||
- `actor()` references the full actor.
|
||||
- `actor('id')` references `actor.id`.
|
||||
- `resource()` references the full resource.
|
||||
- `resource('ownerId')` references `resource.ownerId`.
|
||||
- `ctx('risk.mfa')` references `context.risk.mfa`.
|
||||
- `attr('actor.role')`, `attr('post.visibility')`, `attr('context.risk.mfa')` are convenient path refs.
|
||||
|
||||
## Providers
|
||||
|
||||
Policies should stay declarative. Providers resolve data that is not already present.
|
||||
|
||||
### Relation Provider
|
||||
|
||||
Use a relation provider for ownership, membership and graph-like checks.
|
||||
|
||||
```ts
|
||||
const Permissions = createEnginePermissions({
|
||||
schema,
|
||||
policies,
|
||||
providers: {
|
||||
relations: {
|
||||
hasRelation({ relation, resource, subject }) {
|
||||
if (relation === 'post.owner') {
|
||||
return resource.ownerId === subject.id;
|
||||
}
|
||||
|
||||
if (relation === 'post.team.member') {
|
||||
return Array.isArray(subject.teamIds) && subject.teamIds.includes(resource.teamId);
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Return values:
|
||||
|
||||
- `true`: relation exists.
|
||||
- `false`: relation does not exist.
|
||||
- `'unknown'`: provider cannot answer safely.
|
||||
|
||||
Use `'unknown'` instead of guessing. Unknown deny fails closed.
|
||||
|
||||
### Attribute Provider
|
||||
|
||||
Use an attribute provider when actor/resource/context attributes should be resolved lazily.
|
||||
|
||||
```ts
|
||||
providers: {
|
||||
attributes: {
|
||||
getAttribute({ root, path, context }) {
|
||||
// Example: load an attribute from another source.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Most apps can start without an attribute provider by passing needed attributes directly on the
|
||||
actor, resource or context.
|
||||
|
||||
## Engine Usage
|
||||
|
||||
Create the server-side runtime:
|
||||
|
||||
```ts
|
||||
export const Permissions = createEnginePermissions({
|
||||
schema,
|
||||
policies,
|
||||
providers,
|
||||
compilers: [createSqlCompiler()]
|
||||
});
|
||||
```
|
||||
|
||||
Check a permission:
|
||||
|
||||
```ts
|
||||
const decision = await Permissions.check({
|
||||
actor,
|
||||
action: 'post.update',
|
||||
resource: post,
|
||||
context: { risk: { mfa: true } }
|
||||
});
|
||||
|
||||
if (decision.effect !== 'allow') {
|
||||
// return 403, throw, log, explain, etc.
|
||||
}
|
||||
```
|
||||
|
||||
Assert a permission:
|
||||
|
||||
```ts
|
||||
await Permissions.assert({
|
||||
actor,
|
||||
action: 'post.delete',
|
||||
resource: post
|
||||
});
|
||||
```
|
||||
|
||||
`assert()` throws `PermissionDeniedError` for every non-allow decision.
|
||||
|
||||
Use `can()` only when a boolean is enough:
|
||||
|
||||
```ts
|
||||
if (await Permissions.can({ actor, action: 'post.read', resource: post })) {
|
||||
return post;
|
||||
}
|
||||
```
|
||||
|
||||
## Server Route Pattern
|
||||
|
||||
The server must enforce permissions before reading or mutating protected data.
|
||||
|
||||
```ts
|
||||
export async function updatePost(event) {
|
||||
const actor = await resolveActor(event);
|
||||
const post = await loadPost(event.params.id);
|
||||
|
||||
await Permissions.assert({
|
||||
actor,
|
||||
action: 'post.update',
|
||||
resource: post,
|
||||
context: { tenant: event.locals.tenant }
|
||||
});
|
||||
|
||||
return savePost(post, await event.request.json());
|
||||
}
|
||||
```
|
||||
|
||||
Do not rely on `<Can />` or `ActivePermissions.can()` for this.
|
||||
|
||||
## HTTP Handlers
|
||||
|
||||
The active client talks to HTTP handlers.
|
||||
|
||||
```ts
|
||||
import { createPermissionHttpHandlers } from '$perm';
|
||||
import { Permissions } from '$lib/server/permissions';
|
||||
|
||||
const handlers = createPermissionHttpHandlers(Permissions, async (request) => {
|
||||
const session = await readSession(request);
|
||||
return {
|
||||
type: 'user',
|
||||
id: session.user.id,
|
||||
status: session.user.status,
|
||||
role: session.user.role,
|
||||
teamIds: session.user.teamIds
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
The returned object contains:
|
||||
|
||||
- `check(request)`
|
||||
- `batch(request)`
|
||||
- `what(request)`
|
||||
- `explain(request)`
|
||||
|
||||
Expose those from your SvelteKit route however your routing convention prefers.
|
||||
|
||||
Conceptual route shape:
|
||||
|
||||
```ts
|
||||
// POST /api/permissions/check
|
||||
return json(await handlers.check(request));
|
||||
|
||||
// POST /api/permissions/batch
|
||||
return json(await handlers.batch(request));
|
||||
|
||||
// POST /api/permissions/what
|
||||
return json(await handlers.what(request));
|
||||
|
||||
// POST /api/permissions/explain
|
||||
return json(await handlers.explain(request));
|
||||
```
|
||||
|
||||
## Active Client
|
||||
|
||||
Create the client directly:
|
||||
|
||||
```ts
|
||||
const Permissions = createActivePermissions({
|
||||
endpoint: '/api/permissions',
|
||||
cacheTtlMs: 30_000
|
||||
});
|
||||
```
|
||||
|
||||
Or through App:
|
||||
|
||||
```ts
|
||||
const App = createActiveApp({
|
||||
permissions: {
|
||||
endpoint: '/api/permissions',
|
||||
cacheTtlMs: 30_000
|
||||
}
|
||||
});
|
||||
|
||||
const Permissions = App.createActivePermissions();
|
||||
```
|
||||
|
||||
`App.createActivePermissions()` injects:
|
||||
|
||||
- `App.Http`
|
||||
- `App.Logger`
|
||||
|
||||
The endpoint remains explicit because the client is remote by design.
|
||||
|
||||
Active client API:
|
||||
|
||||
```ts
|
||||
await Permissions.check({ action, resource, context });
|
||||
await Permissions.can({ action, resource, context });
|
||||
await Permissions.batch({ checks });
|
||||
await Permissions.what({ resource, actions, context });
|
||||
await Permissions.explain({ action, resource, context });
|
||||
|
||||
Permissions.hydrate(snapshot);
|
||||
Permissions.snapshot();
|
||||
Permissions.invalidate();
|
||||
Permissions.subscribe((snapshot) => {});
|
||||
```
|
||||
|
||||
Reactive fields:
|
||||
|
||||
```ts
|
||||
Permissions.currentSnapshot;
|
||||
Permissions.decisions;
|
||||
Permissions.size;
|
||||
Permissions.loading;
|
||||
Permissions.lastError;
|
||||
```
|
||||
|
||||
## Snapshots And Cache
|
||||
|
||||
The active client has a small decision cache.
|
||||
|
||||
```ts
|
||||
const Permissions = createActivePermissions({
|
||||
endpoint: '/api/permissions',
|
||||
initialSnapshot,
|
||||
cacheTtlMs: 10_000
|
||||
});
|
||||
```
|
||||
|
||||
Snapshot shape:
|
||||
|
||||
```ts
|
||||
interface PermissionSnapshot {
|
||||
actor?: SubjectRef;
|
||||
version?: string;
|
||||
decisions?: Record<string, PermissionDecision>;
|
||||
global?: Record<string, boolean | PermissionDecision>;
|
||||
expiresAt?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Use snapshots for SSR hydration or first paint.
|
||||
|
||||
Important:
|
||||
|
||||
- Cache is for UX.
|
||||
- Cache is not security.
|
||||
- Mutations still require server-side `assert()`.
|
||||
- Call `invalidate()` after actor/session/resource changes.
|
||||
|
||||
## The `<Can />` Component
|
||||
|
||||
`<Can />` is a small Svelte component that renders its children only when
|
||||
`Permissions.can(...)` returns `true`.
|
||||
|
||||
It reads the active client from Svelte context:
|
||||
|
||||
```ts
|
||||
import { setPermissionsContext } from '$perm';
|
||||
|
||||
const Permissions = App.createActivePermissions();
|
||||
setPermissionsContext(Permissions);
|
||||
```
|
||||
|
||||
Basic usage:
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import Can from '$perm/Can.svelte';
|
||||
</script>
|
||||
|
||||
<Can action="post.update" resource={post}>
|
||||
<button>Edit post</button>
|
||||
|
||||
{#snippet fallback()}
|
||||
<span>You cannot edit this post.</span>
|
||||
{/snippet}
|
||||
|
||||
{#snippet loading()}
|
||||
<span>Checking permissions...</span>
|
||||
{/snippet}
|
||||
</Can>
|
||||
```
|
||||
|
||||
Props:
|
||||
|
||||
- `action`: permission action, for example `'post.update'`.
|
||||
- `resource`: optional resource object.
|
||||
- `context`: optional decision context.
|
||||
- `children`: rendered on allow.
|
||||
- `fallback`: rendered on deny, indeterminate, not_applicable or request failure.
|
||||
- `loading`: rendered while the async check is in progress.
|
||||
|
||||
Again: `<Can />` is only UI. It prevents confusing affordances; it does not protect data.
|
||||
|
||||
## what()
|
||||
|
||||
Use `what()` when a view needs the full action matrix for one resource.
|
||||
|
||||
```ts
|
||||
const actions = await Permissions.what({
|
||||
actor,
|
||||
resource: post
|
||||
});
|
||||
|
||||
actions['post.read'];
|
||||
actions['post.update'];
|
||||
actions['post.delete'];
|
||||
```
|
||||
|
||||
Client-side:
|
||||
|
||||
```ts
|
||||
const actions = await Permissions.what({
|
||||
resource: post,
|
||||
actions: ['post.read', 'post.update']
|
||||
});
|
||||
```
|
||||
|
||||
This is better than firing many separate checks for a toolbar or detail page.
|
||||
|
||||
## explain()
|
||||
|
||||
Use `explain()` for debugging, audit panels and tests.
|
||||
|
||||
```ts
|
||||
const result = await Permissions.explain({
|
||||
actor,
|
||||
action: 'post.publish',
|
||||
resource: post,
|
||||
context: { risk: { mfa: false } }
|
||||
});
|
||||
|
||||
console.log(result.decision);
|
||||
console.table(result.trace);
|
||||
console.log(result.dependencies);
|
||||
```
|
||||
|
||||
`explain()` returns:
|
||||
|
||||
- `decision`: final combined decision.
|
||||
- `trace`: every target policy and condition result.
|
||||
- `dependencies`: actor/resource/context/relation keys used during evaluation.
|
||||
|
||||
Do not expose full explanations to untrusted users unless you intentionally want to reveal policy
|
||||
details.
|
||||
|
||||
## filter()
|
||||
|
||||
Use `filter(action)` to turn authorization into a list query.
|
||||
|
||||
In-memory predicate:
|
||||
|
||||
```ts
|
||||
const canRead = await Permissions.filter('post.read').for(actor).resource('post').toPredicate();
|
||||
|
||||
const visible = [];
|
||||
for (const post of posts) {
|
||||
if (await canRead(post)) visible.push(post);
|
||||
}
|
||||
```
|
||||
|
||||
Query plan:
|
||||
|
||||
```ts
|
||||
const plan = await Permissions.filter('post.read')
|
||||
.for(actor)
|
||||
.resource('post')
|
||||
.context({ tenant: 'acme' })
|
||||
.toPlan('sql');
|
||||
```
|
||||
|
||||
SQL compiler:
|
||||
|
||||
```ts
|
||||
const Permissions = createEnginePermissions({
|
||||
schema,
|
||||
policies,
|
||||
compilers: [
|
||||
createSqlCompiler({
|
||||
resourceAlias: 'post',
|
||||
relation({ relation, resourceAlias, actor, param }) {
|
||||
if (relation === 'post.owner') {
|
||||
return `${resourceAlias}.owner_id = ${param(actor.id)}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
})
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
Compilation is conservative:
|
||||
|
||||
- Fully compilable policies produce `strategy: 'compiled'`.
|
||||
- Partially compilable policies produce `strategy: 'partial'` and `residualPolicies`.
|
||||
- Non-compilable policies produce `strategy: 'not_compilable'`.
|
||||
|
||||
Unsupported policies are never silently erased.
|
||||
|
||||
## Obligations And Advice
|
||||
|
||||
An allow can carry obligations:
|
||||
|
||||
```ts
|
||||
allow('post.read')
|
||||
.id('post.read.public')
|
||||
.when(attr('post.visibility').eq('public'))
|
||||
.oblige(mask('internalNotes'));
|
||||
```
|
||||
|
||||
Common obligations:
|
||||
|
||||
- `mask(field, mode?)`
|
||||
- `redact(field)`
|
||||
- `audit(event, severity?)`
|
||||
- `requireMfa(reason?)`
|
||||
|
||||
Obligations are returned in the decision. It is the caller's job to enforce them.
|
||||
|
||||
Example:
|
||||
|
||||
```ts
|
||||
const decision = await Permissions.check({ actor, action: 'post.read', resource: post });
|
||||
|
||||
if (decision.effect === 'allow') {
|
||||
return applyObligations(post, decision.obligations);
|
||||
}
|
||||
```
|
||||
|
||||
Advice is similar but non-mandatory.
|
||||
|
||||
## Integration With Other Artifacts
|
||||
|
||||
### aapp
|
||||
|
||||
`App.createActivePermissions()` builds the UI client and injects `App.Http` and `App.Logger`.
|
||||
|
||||
```ts
|
||||
const App = createActiveApp({
|
||||
permissions: { endpoint: '/api/permissions' }
|
||||
});
|
||||
|
||||
const Permissions = App.createActivePermissions();
|
||||
```
|
||||
|
||||
`App.Permissions` is `undefined` until `createActivePermissions()` is called.
|
||||
|
||||
### sess
|
||||
|
||||
Session/authentication resolves the actor. `perm` does not log users in and does not own tokens.
|
||||
|
||||
Typical flow:
|
||||
|
||||
```ts
|
||||
const actor = actorFromSession(App.Sess.current);
|
||||
await Permissions.assert({ actor, action, resource });
|
||||
```
|
||||
|
||||
On the server, resolve the actor from server-side session state, not from a client payload.
|
||||
|
||||
### http
|
||||
|
||||
The active client can use `App.Http`, so existing headers, fetch scoping and hooks apply.
|
||||
|
||||
### conn
|
||||
|
||||
Realtime channels should use `perm` on the server side before allowing joins, sends or privileged
|
||||
events. A client-side `<Can />` around a chat button is UX only.
|
||||
|
||||
### logr
|
||||
|
||||
`createEnginePermissions({ logger })` emits structured logs under category `'perm'` for decisions,
|
||||
denials and indeterminate decisions.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit-test policies as data.
|
||||
|
||||
```ts
|
||||
it('denies suspended users', async () => {
|
||||
const decision = await Permissions.check({
|
||||
actor: { type: 'user', id: 'u1', status: 'suspended' },
|
||||
action: 'post.read',
|
||||
resource: { type: 'post', id: 'p1', visibility: 'public' }
|
||||
});
|
||||
|
||||
expect(decision.effect).toBe('deny');
|
||||
});
|
||||
```
|
||||
|
||||
Test dangerous cases:
|
||||
|
||||
- Deny wins over allow.
|
||||
- Unknown deny fails closed.
|
||||
- Missing relation provider does not allow access.
|
||||
- `what()` returns the expected action matrix.
|
||||
- `filter().toPlan()` does not erase residual policies.
|
||||
- Client cache invalidates when actor/resource/context changes.
|
||||
|
||||
There is an interactive page at:
|
||||
|
||||
```txt
|
||||
/test/perm
|
||||
```
|
||||
|
||||
It exercises:
|
||||
|
||||
- `createEnginePermissions()`
|
||||
- `App.createActivePermissions()`
|
||||
- HTTP handlers
|
||||
- client cache
|
||||
- `<Can />`
|
||||
- `check()`
|
||||
- `what()`
|
||||
- `explain()`
|
||||
- SQL query plan
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- Always enforce permissions on the server.
|
||||
- Treat `ActivePermissions` and `<Can />` as UI helpers only.
|
||||
- Never trust actor data sent by the browser.
|
||||
- Prefer stable policy ids.
|
||||
- Prefer constants for shared actions, relation names and policy ids.
|
||||
- Return `'unknown'` from providers when data cannot be resolved safely.
|
||||
- Review every `indeterminate` as denied unless there is a deliberate exception.
|
||||
- Do not expose `explain()` details to users unless policy disclosure is acceptable.
|
||||
- Apply obligations explicitly.
|
||||
- Invalidate client cache after login, logout, session refresh, role change or resource mutation.
|
||||
|
||||
## Minimal Complete Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
actor,
|
||||
allow,
|
||||
and,
|
||||
attr,
|
||||
createEnginePermissions,
|
||||
definePermSchema,
|
||||
definePolicies,
|
||||
deny,
|
||||
rel
|
||||
} from '$perm';
|
||||
|
||||
const schema = definePermSchema({
|
||||
actors: {
|
||||
user: { attributes: { status: 'string', role: 'string' } }
|
||||
},
|
||||
resources: {
|
||||
post: { actions: ['read', 'update'] }
|
||||
},
|
||||
relations: {
|
||||
'post.owner': { from: 'post', to: 'user' }
|
||||
}
|
||||
});
|
||||
|
||||
const policies = definePolicies(schema, [
|
||||
deny('post.*')
|
||||
.id('post.deny.suspended')
|
||||
.priority(1000)
|
||||
.when(attr('actor.status').eq('suspended')),
|
||||
|
||||
allow('post.read').id('post.read.public').when(attr('post.visibility').eq('public')),
|
||||
|
||||
allow('post.update')
|
||||
.id('post.update.owner')
|
||||
.when(and(rel('post.owner').is(actor()), attr('post.status').notEq('archived')))
|
||||
]);
|
||||
|
||||
export const Permissions = createEnginePermissions({
|
||||
schema,
|
||||
policies,
|
||||
providers: {
|
||||
relations: {
|
||||
hasRelation({ relation, resource, subject }) {
|
||||
if (relation === 'post.owner') return resource.ownerId === subject.id;
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Usage:
|
||||
|
||||
```ts
|
||||
await Permissions.assert({
|
||||
actor: { type: 'user', id: 'u1', status: 'active' },
|
||||
action: 'post.update',
|
||||
resource: {
|
||||
type: 'post',
|
||||
id: 'p1',
|
||||
ownerId: 'u1',
|
||||
status: 'draft',
|
||||
visibility: 'private'
|
||||
}
|
||||
});
|
||||
```
|
||||
@ -0,0 +1,70 @@
|
||||
import { untrack } from 'svelte';
|
||||
import { createPermissionClient } from './client.ts';
|
||||
import { PERMISSION_ERROR_MSG_CLIENT_ENDPOINT_REQUIRED } from './consts.ts';
|
||||
import type { ActivePermissions, ActivePermissionsOptions, PermissionSnapshot } from './types.ts';
|
||||
|
||||
export function createActivePermissions(options: ActivePermissionsOptions): ActivePermissions {
|
||||
if (!options.endpoint) throw new Error(PERMISSION_ERROR_MSG_CLIENT_ENDPOINT_REQUIRED);
|
||||
|
||||
const client = createPermissionClient(options);
|
||||
let snapshotCell = $state<PermissionSnapshot>(client.snapshot());
|
||||
let loadingCount = $state(0);
|
||||
let lastErrorCell = $state<unknown | null>(null);
|
||||
|
||||
const off = client.subscribe((snapshot) => {
|
||||
snapshotCell = snapshot;
|
||||
});
|
||||
|
||||
function updateLoading(delta: number): void {
|
||||
loadingCount = Math.max(0, untrack(() => loadingCount) + delta);
|
||||
}
|
||||
|
||||
async function track<T>(task: () => Promise<T>): Promise<T> {
|
||||
updateLoading(1);
|
||||
try {
|
||||
const value = await task();
|
||||
lastErrorCell = null;
|
||||
return value;
|
||||
} catch (error) {
|
||||
lastErrorCell = error;
|
||||
throw error;
|
||||
} finally {
|
||||
updateLoading(-1);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get currentSnapshot() {
|
||||
return snapshotCell;
|
||||
},
|
||||
get decisions() {
|
||||
return snapshotCell.decisions ?? {};
|
||||
},
|
||||
get size() {
|
||||
return Object.keys(snapshotCell.decisions ?? {}).length;
|
||||
},
|
||||
get loading() {
|
||||
return loadingCount > 0;
|
||||
},
|
||||
get lastError() {
|
||||
return lastErrorCell;
|
||||
},
|
||||
check: (input) => track(() => client.check(input)),
|
||||
can: (input) => track(() => client.can(input)),
|
||||
batch: (input) => track(() => client.batch(input)),
|
||||
what: (input) => track(() => client.what(input)),
|
||||
explain: (input) => track(() => client.explain(input)),
|
||||
hydrate(snapshot) {
|
||||
client.hydrate(snapshot);
|
||||
},
|
||||
snapshot: () => client.snapshot(),
|
||||
invalidate(scope) {
|
||||
client.invalidate(scope);
|
||||
},
|
||||
subscribe: (listener) => client.subscribe(listener),
|
||||
decisionKey: (input) => client.decisionKey(input),
|
||||
dispose() {
|
||||
off();
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,285 @@
|
||||
import {
|
||||
PERMISSION_DECISION_CODE_SNAPSHOT_DENIED,
|
||||
PERMISSION_EFFECT_ALLOW,
|
||||
PERMISSION_EFFECT_DENY,
|
||||
PERMISSION_EFFECT_INDETERMINATE,
|
||||
PERMISSION_FALLBACK_DENY
|
||||
} from '$libs/perm';
|
||||
import { HTTP_CONTENT_TYPE_JSON, HTTP_HEADER_CONTENT_TYPE, HTTP_METHOD_POST } from '$libs/http';
|
||||
import { permissionDecisionKey } from './keys.ts';
|
||||
import {
|
||||
LOGGER_CATEGORY,
|
||||
PERMISSION_CLIENT_DEFAULT_CACHE_TTL_MS,
|
||||
PERMISSION_CLIENT_PATH_BATCH,
|
||||
PERMISSION_CLIENT_PATH_CHECK,
|
||||
PERMISSION_CLIENT_PATH_EXPLAIN,
|
||||
PERMISSION_CLIENT_PATH_WHAT,
|
||||
PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX,
|
||||
PERMISSION_HTTP_CREDENTIALS_INCLUDE,
|
||||
PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED,
|
||||
PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED,
|
||||
PERMISSION_LOG_MSG_REMOTE_WHAT_FAILED,
|
||||
PERMISSION_REQUEST_FIELD_ACTION,
|
||||
PERMISSION_REQUEST_FIELD_CHECKS,
|
||||
PERMISSION_REQUEST_FIELD_CONTEXT,
|
||||
PERMISSION_REQUEST_FIELD_RESOURCE,
|
||||
PERMISSION_RESPONSE_FIELD_ACTIONS,
|
||||
PERMISSION_RESPONSE_FIELD_DECISIONS,
|
||||
PERMISSION_SNAPSHOT_GLOBAL_POLICY
|
||||
} from './consts.ts';
|
||||
import type {
|
||||
PermissionClient,
|
||||
PermissionClientBatchInput,
|
||||
PermissionClientCheckInput,
|
||||
PermissionClientOptions,
|
||||
PermissionSnapshot
|
||||
} from './types.ts';
|
||||
import type { ExplainResult, PermissionDecision } from '$libs/perm';
|
||||
|
||||
interface CacheEntry {
|
||||
readonly decision: PermissionDecision;
|
||||
readonly expiresAt: number;
|
||||
}
|
||||
|
||||
function now(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function joinUrl(base: string, path: string): string {
|
||||
return `${base.replace(/\/$/, '')}/${path.replace(/^\//, '')}`;
|
||||
}
|
||||
|
||||
async function postJson<T>(
|
||||
options: PermissionClientOptions,
|
||||
path: string,
|
||||
body: unknown
|
||||
): Promise<T> {
|
||||
const url = joinUrl(options.endpoint, path);
|
||||
if (options.http) {
|
||||
const response = await options.http.post(url, { body: body as Record<string, unknown> });
|
||||
if (response.ok) return response.value as T;
|
||||
throw new Error(`${PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX}${url}`);
|
||||
}
|
||||
|
||||
const fetcher = options.fetcher ?? fetch.bind(globalThis);
|
||||
const response = await fetcher(url, {
|
||||
method: HTTP_METHOD_POST,
|
||||
headers: { [HTTP_HEADER_CONTENT_TYPE]: HTTP_CONTENT_TYPE_JSON },
|
||||
credentials: PERMISSION_HTTP_CREDENTIALS_INCLUDE,
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`${PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX}${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export function createPermissionClient(options: PermissionClientOptions): PermissionClient {
|
||||
const cacheTtlMs = options.cacheTtlMs ?? PERMISSION_CLIENT_DEFAULT_CACHE_TTL_MS;
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
const pending = new Map<string, Promise<PermissionDecision>>();
|
||||
const listeners = new Set<(snapshot: PermissionSnapshot) => void>();
|
||||
let currentSnapshot: PermissionSnapshot = options.initialSnapshot ?? { decisions: {} };
|
||||
|
||||
function emit(): void {
|
||||
for (const listener of listeners) listener(currentSnapshot);
|
||||
}
|
||||
|
||||
function decisionKey(input: PermissionClientCheckInput): string {
|
||||
return permissionDecisionKey(input);
|
||||
}
|
||||
|
||||
function snapshotStillValid(snapshot: PermissionSnapshot): boolean {
|
||||
return snapshot.expiresAt === undefined || Date.parse(snapshot.expiresAt) > now();
|
||||
}
|
||||
|
||||
function readSnapshotDecision(input: PermissionClientCheckInput): PermissionDecision | undefined {
|
||||
if (!snapshotStillValid(currentSnapshot)) return undefined;
|
||||
const key = decisionKey(input);
|
||||
const direct = currentSnapshot.decisions?.[key];
|
||||
if (direct) return direct;
|
||||
const global = currentSnapshot.global?.[input.action];
|
||||
if (typeof global === 'boolean') {
|
||||
return global
|
||||
? { effect: PERMISSION_EFFECT_ALLOW, policy: PERMISSION_SNAPSHOT_GLOBAL_POLICY }
|
||||
: {
|
||||
effect: PERMISSION_EFFECT_DENY,
|
||||
code: PERMISSION_DECISION_CODE_SNAPSHOT_DENIED,
|
||||
reason: PERMISSION_SNAPSHOT_GLOBAL_POLICY
|
||||
};
|
||||
}
|
||||
return global;
|
||||
}
|
||||
|
||||
function setCached(input: PermissionClientCheckInput, decision: PermissionDecision): void {
|
||||
const key = decisionKey(input);
|
||||
const ttl =
|
||||
decision.effect === PERMISSION_EFFECT_ALLOW && decision.ttl ? decision.ttl : cacheTtlMs;
|
||||
cache.set(key, { decision, expiresAt: now() + ttl });
|
||||
currentSnapshot = {
|
||||
...currentSnapshot,
|
||||
decisions: {
|
||||
...(currentSnapshot.decisions ?? {}),
|
||||
[key]: decision
|
||||
}
|
||||
};
|
||||
emit();
|
||||
}
|
||||
|
||||
async function check(input: PermissionClientCheckInput): Promise<PermissionDecision> {
|
||||
const key = decisionKey(input);
|
||||
const cached = cache.get(key);
|
||||
if (cached && cached.expiresAt > now()) return cached.decision;
|
||||
|
||||
const snapshotDecision = readSnapshotDecision(input);
|
||||
if (snapshotDecision) {
|
||||
cache.set(key, { decision: snapshotDecision, expiresAt: now() + cacheTtlMs });
|
||||
return snapshotDecision;
|
||||
}
|
||||
|
||||
const inFlight = pending.get(key);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
const request = postJson<PermissionDecision>(options, PERMISSION_CLIENT_PATH_CHECK, {
|
||||
[PERMISSION_REQUEST_FIELD_ACTION]: input.action,
|
||||
[PERMISSION_REQUEST_FIELD_RESOURCE]: input.resource,
|
||||
[PERMISSION_REQUEST_FIELD_CONTEXT]: input.context
|
||||
})
|
||||
.then((decision) => {
|
||||
setCached(input, decision);
|
||||
return decision;
|
||||
})
|
||||
.catch((error) => {
|
||||
options.onError?.(error);
|
||||
options.logger?.error?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED, {
|
||||
error,
|
||||
context: { input }
|
||||
});
|
||||
return {
|
||||
effect: PERMISSION_EFFECT_INDETERMINATE,
|
||||
reason: PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED,
|
||||
fallback: PERMISSION_FALLBACK_DENY,
|
||||
errors: [error]
|
||||
} satisfies PermissionDecision;
|
||||
})
|
||||
.finally(() => {
|
||||
pending.delete(key);
|
||||
});
|
||||
pending.set(key, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
async function batch(
|
||||
input: PermissionClientBatchInput
|
||||
): Promise<Record<string, PermissionDecision>> {
|
||||
try {
|
||||
const result = await postJson<{ decisions: Record<string, PermissionDecision> }>(
|
||||
options,
|
||||
PERMISSION_CLIENT_PATH_BATCH,
|
||||
{ [PERMISSION_REQUEST_FIELD_CHECKS]: input.checks }
|
||||
);
|
||||
for (const item of input.checks) {
|
||||
const key = decisionKey(item);
|
||||
const decision = result[PERMISSION_RESPONSE_FIELD_DECISIONS][key];
|
||||
if (decision) setCached(item, decision);
|
||||
}
|
||||
return result[PERMISSION_RESPONSE_FIELD_DECISIONS];
|
||||
} catch (error) {
|
||||
options.onError?.(error);
|
||||
options.logger?.error?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED, {
|
||||
error,
|
||||
context: { input }
|
||||
});
|
||||
const decisions: Record<string, PermissionDecision> = {};
|
||||
for (const item of input.checks) {
|
||||
decisions[decisionKey(item)] = {
|
||||
effect: PERMISSION_EFFECT_INDETERMINATE,
|
||||
reason: PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED,
|
||||
fallback: PERMISSION_FALLBACK_DENY,
|
||||
errors: [error]
|
||||
};
|
||||
}
|
||||
return decisions;
|
||||
}
|
||||
}
|
||||
|
||||
async function what(input: {
|
||||
readonly resource?: PermissionClientCheckInput['resource'];
|
||||
readonly actions?: readonly string[];
|
||||
readonly context?: PermissionClientCheckInput['context'];
|
||||
}): Promise<Record<string, PermissionDecision>> {
|
||||
try {
|
||||
const result = await postJson<{ actions: Record<string, PermissionDecision> }>(
|
||||
options,
|
||||
PERMISSION_CLIENT_PATH_WHAT,
|
||||
input
|
||||
);
|
||||
for (const [action, decision] of Object.entries(result[PERMISSION_RESPONSE_FIELD_ACTIONS])) {
|
||||
setCached({ action, resource: input.resource, context: input.context }, decision);
|
||||
}
|
||||
return result[PERMISSION_RESPONSE_FIELD_ACTIONS];
|
||||
} catch (error) {
|
||||
options.onError?.(error);
|
||||
options.logger?.error?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_REMOTE_WHAT_FAILED, {
|
||||
error,
|
||||
context: { input }
|
||||
});
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function explain(input: PermissionClientCheckInput): Promise<ExplainResult | null> {
|
||||
try {
|
||||
return await postJson<ExplainResult>(options, PERMISSION_CLIENT_PATH_EXPLAIN, input);
|
||||
} catch (error) {
|
||||
options.onError?.(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hydrate(snapshot: PermissionSnapshot): void {
|
||||
currentSnapshot = snapshot;
|
||||
cache.clear();
|
||||
for (const [key, decision] of Object.entries(snapshot.decisions ?? {})) {
|
||||
cache.set(key, { decision, expiresAt: now() + cacheTtlMs });
|
||||
}
|
||||
emit();
|
||||
}
|
||||
|
||||
function invalidate(scope?: string): void {
|
||||
if (!scope) {
|
||||
cache.clear();
|
||||
currentSnapshot = { ...currentSnapshot, decisions: {} };
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
for (const key of [...cache.keys()]) if (key.includes(scope)) cache.delete(key);
|
||||
const decisions = { ...(currentSnapshot.decisions ?? {}) };
|
||||
for (const key of Object.keys(decisions)) if (key.includes(scope)) delete decisions[key];
|
||||
currentSnapshot = { ...currentSnapshot, decisions };
|
||||
emit();
|
||||
}
|
||||
|
||||
return {
|
||||
check,
|
||||
async can(input) {
|
||||
return (await check(input)).effect === PERMISSION_EFFECT_ALLOW;
|
||||
},
|
||||
batch,
|
||||
what,
|
||||
explain,
|
||||
hydrate,
|
||||
snapshot: () => currentSnapshot,
|
||||
invalidate,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
listener(currentSnapshot);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
decisionKey
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
export const LOGGER_CATEGORY = 'perm';
|
||||
export const PERMISSION_CONTEXT_KEY = 'active.permissions';
|
||||
|
||||
export const PERMISSION_CLIENT_PATH_CHECK = '/check';
|
||||
export const PERMISSION_CLIENT_PATH_BATCH = '/batch';
|
||||
export const PERMISSION_CLIENT_PATH_WHAT = '/what';
|
||||
export const PERMISSION_CLIENT_PATH_EXPLAIN = '/explain';
|
||||
|
||||
export const PERMISSION_CLIENT_DEFAULT_CACHE_TTL_MS = 30_000;
|
||||
export const PERMISSION_SNAPSHOT_DECISIONS_KEY = 'decisions';
|
||||
export const PERMISSION_SNAPSHOT_GLOBAL_POLICY = 'snapshot.global';
|
||||
|
||||
export const PERMISSION_CLIENT_KEY_SEPARATOR = ':';
|
||||
export const PERMISSION_CLIENT_KEY_GLOBAL = 'global';
|
||||
export const PERMISSION_CLIENT_KEY_NONE = 'none';
|
||||
export const PERMISSION_CLIENT_CONTEXT_EMPTY = '';
|
||||
|
||||
export const PERMISSION_HTTP_STATUS_OK = 200;
|
||||
export const PERMISSION_HTTP_STATUS_BAD_REQUEST = 400;
|
||||
export const PERMISSION_HTTP_STATUS_FORBIDDEN = 403;
|
||||
|
||||
export const PERMISSION_HTTP_CREDENTIALS_INCLUDE = 'include';
|
||||
|
||||
export const PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED = 'remote authorization check failed';
|
||||
export const PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED = 'remote authorization batch failed';
|
||||
export const PERMISSION_LOG_MSG_REMOTE_WHAT_FAILED = 'remote authorization what failed';
|
||||
export const PERMISSION_LOG_MSG_DECISION = 'authorization decision';
|
||||
export const PERMISSION_LOG_MSG_DENIED = 'authorization denied';
|
||||
export const PERMISSION_LOG_MSG_INDETERMINATE = 'authorization indeterminate';
|
||||
|
||||
export const PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX = 'Authorization request failed: ';
|
||||
export const PERMISSION_ERROR_MSG_BODY_MUST_BE_OBJECT = 'Request body must be an object';
|
||||
export const PERMISSION_ERROR_MSG_NO_CONTEXT = 'Permission context is not available';
|
||||
export const PERMISSION_ERROR_MSG_CLIENT_ENDPOINT_REQUIRED =
|
||||
'createActivePermissions requires an endpoint';
|
||||
|
||||
export const PERMISSION_REQUEST_FIELD_ACTION = 'action';
|
||||
export const PERMISSION_REQUEST_FIELD_RESOURCE = 'resource';
|
||||
export const PERMISSION_REQUEST_FIELD_CONTEXT = 'context';
|
||||
export const PERMISSION_REQUEST_FIELD_CHECKS = 'checks';
|
||||
export const PERMISSION_REQUEST_FIELD_ACTIONS = 'actions';
|
||||
export const PERMISSION_RESPONSE_FIELD_DECISIONS = 'decisions';
|
||||
export const PERMISSION_RESPONSE_FIELD_ACTIONS = 'actions';
|
||||
|
||||
export const PERMISSION_ACTIVE_EVENT_HYDRATE = 'hydrate';
|
||||
export const PERMISSION_ACTIVE_EVENT_CHECK = 'check';
|
||||
export const PERMISSION_ACTIVE_EVENT_BATCH = 'batch';
|
||||
export const PERMISSION_ACTIVE_EVENT_WHAT = 'what';
|
||||
export const PERMISSION_ACTIVE_EVENT_INVALIDATE = 'invalidate';
|
||||
@ -0,0 +1,16 @@
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import { PERMISSION_CONTEXT_KEY, PERMISSION_ERROR_MSG_NO_CONTEXT } from './consts.ts';
|
||||
import type { ActivePermissions } from './types.ts';
|
||||
|
||||
const PERMISSION_CONTEXT = Symbol(PERMISSION_CONTEXT_KEY);
|
||||
|
||||
export function setPermissionsContext(client: ActivePermissions): ActivePermissions {
|
||||
setContext(PERMISSION_CONTEXT, client);
|
||||
return client;
|
||||
}
|
||||
|
||||
export function getPermissionsContext(): ActivePermissions {
|
||||
const client = getContext<ActivePermissions | undefined>(PERMISSION_CONTEXT);
|
||||
if (!client) throw new Error(PERMISSION_ERROR_MSG_NO_CONTEXT);
|
||||
return client;
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
import {
|
||||
PERMISSION_EFFECT_ALLOW,
|
||||
PERMISSION_EFFECT_DENY,
|
||||
PERMISSION_EFFECT_INDETERMINATE,
|
||||
PermissionDeniedError,
|
||||
createPermissionRuntime
|
||||
} from '$libs/perm';
|
||||
import {
|
||||
LOGGER_CATEGORY,
|
||||
PERMISSION_LOG_MSG_DECISION,
|
||||
PERMISSION_LOG_MSG_DENIED,
|
||||
PERMISSION_LOG_MSG_INDETERMINATE
|
||||
} from './consts.ts';
|
||||
import type { EnginePermissions, EnginePermissionsOptions } from './types.ts';
|
||||
|
||||
export function createEnginePermissions(options: EnginePermissionsOptions): EnginePermissions {
|
||||
const runtime = createPermissionRuntime(options);
|
||||
let disposed = false;
|
||||
|
||||
async function check(input: Parameters<typeof runtime.check>[0]) {
|
||||
const decision = await runtime.check(input);
|
||||
if (decision.effect === PERMISSION_EFFECT_DENY) {
|
||||
options.logger?.warn?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_DENIED, {
|
||||
context: {
|
||||
action: input.action,
|
||||
resource: input.resource,
|
||||
decision
|
||||
}
|
||||
});
|
||||
} else if (decision.effect === PERMISSION_EFFECT_INDETERMINATE) {
|
||||
options.logger?.warn?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_INDETERMINATE, {
|
||||
context: {
|
||||
action: input.action,
|
||||
resource: input.resource,
|
||||
decision
|
||||
}
|
||||
});
|
||||
} else {
|
||||
options.logger?.debug?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_DECISION, {
|
||||
context: {
|
||||
action: input.action,
|
||||
resource: input.resource,
|
||||
decision
|
||||
}
|
||||
});
|
||||
}
|
||||
return decision;
|
||||
}
|
||||
|
||||
return {
|
||||
schema: options.schema,
|
||||
policies: options.policies,
|
||||
compilers: options.compilers ?? [],
|
||||
check,
|
||||
async can(input) {
|
||||
const decision = await check(input);
|
||||
return decision.effect === PERMISSION_EFFECT_ALLOW;
|
||||
},
|
||||
async assert(input) {
|
||||
const decision = await check(input);
|
||||
if (decision.effect !== PERMISSION_EFFECT_ALLOW) throw new PermissionDeniedError(decision);
|
||||
},
|
||||
explain: (input) => runtime.explain(input),
|
||||
what: (input) => runtime.what(input),
|
||||
who: (input) => runtime.who(input),
|
||||
filter: (action) => runtime.filter(action),
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
import {
|
||||
PERMISSION_ERROR_MSG_BODY_MUST_BE_OBJECT,
|
||||
PERMISSION_HTTP_STATUS_OK,
|
||||
PERMISSION_REQUEST_FIELD_ACTION,
|
||||
PERMISSION_REQUEST_FIELD_ACTIONS,
|
||||
PERMISSION_REQUEST_FIELD_CHECKS,
|
||||
PERMISSION_REQUEST_FIELD_CONTEXT,
|
||||
PERMISSION_REQUEST_FIELD_RESOURCE,
|
||||
PERMISSION_RESPONSE_FIELD_ACTIONS,
|
||||
PERMISSION_RESPONSE_FIELD_DECISIONS
|
||||
} from './consts.ts';
|
||||
import { permissionDecisionKey } from './keys.ts';
|
||||
import type {
|
||||
EnginePermissions,
|
||||
PermissionActorResolver,
|
||||
PermissionClientCheckInput,
|
||||
PermissionHttpRequestLike,
|
||||
PermissionHttpResponse
|
||||
} from './types.ts';
|
||||
import type { PermissionCheckInput } from '$libs/perm';
|
||||
|
||||
function assertBodyObject(body: unknown): asserts body is Record<string, unknown> {
|
||||
if (!body || typeof body !== 'object') throw new Error(PERMISSION_ERROR_MSG_BODY_MUST_BE_OBJECT);
|
||||
}
|
||||
|
||||
function readCheck(body: Record<string, unknown>): PermissionClientCheckInput {
|
||||
return {
|
||||
action: String(body[PERMISSION_REQUEST_FIELD_ACTION]),
|
||||
resource: body[PERMISSION_REQUEST_FIELD_RESOURCE] as PermissionClientCheckInput['resource'],
|
||||
context: body[PERMISSION_REQUEST_FIELD_CONTEXT] as PermissionClientCheckInput['context']
|
||||
};
|
||||
}
|
||||
|
||||
export function createPermissionHttpHandlers(
|
||||
runtime: EnginePermissions,
|
||||
resolveActor: PermissionActorResolver
|
||||
) {
|
||||
return {
|
||||
async check(request: PermissionHttpRequestLike): Promise<PermissionHttpResponse> {
|
||||
const body = await request.json();
|
||||
assertBodyObject(body);
|
||||
const actor = await resolveActor(request, body);
|
||||
const check = readCheck(body);
|
||||
const decision = await runtime.check({ actor, ...check });
|
||||
return { status: PERMISSION_HTTP_STATUS_OK, body: decision };
|
||||
},
|
||||
|
||||
async batch(request: PermissionHttpRequestLike): Promise<PermissionHttpResponse> {
|
||||
const body = await request.json();
|
||||
assertBodyObject(body);
|
||||
const actor = await resolveActor(request, body);
|
||||
const checks = Array.isArray(body[PERMISSION_REQUEST_FIELD_CHECKS])
|
||||
? (body[PERMISSION_REQUEST_FIELD_CHECKS] as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const decisions: Record<string, unknown> = {};
|
||||
|
||||
for (const item of checks) {
|
||||
const check = readCheck(item);
|
||||
decisions[permissionDecisionKey(check)] = await runtime.check({ actor, ...check });
|
||||
}
|
||||
|
||||
return {
|
||||
status: PERMISSION_HTTP_STATUS_OK,
|
||||
body: { [PERMISSION_RESPONSE_FIELD_DECISIONS]: decisions }
|
||||
};
|
||||
},
|
||||
|
||||
async what(request: PermissionHttpRequestLike): Promise<PermissionHttpResponse> {
|
||||
const body = await request.json();
|
||||
assertBodyObject(body);
|
||||
const actor = await resolveActor(request, body);
|
||||
const result = await runtime.what({
|
||||
actor,
|
||||
resource: body[PERMISSION_REQUEST_FIELD_RESOURCE] as PermissionCheckInput['resource'],
|
||||
context: body[PERMISSION_REQUEST_FIELD_CONTEXT] as PermissionCheckInput['context'],
|
||||
actions: Array.isArray(body[PERMISSION_REQUEST_FIELD_ACTIONS])
|
||||
? (body[PERMISSION_REQUEST_FIELD_ACTIONS] as unknown[]).map(String)
|
||||
: undefined
|
||||
});
|
||||
return {
|
||||
status: PERMISSION_HTTP_STATUS_OK,
|
||||
body: { [PERMISSION_RESPONSE_FIELD_ACTIONS]: result.actions }
|
||||
};
|
||||
},
|
||||
|
||||
async explain(request: PermissionHttpRequestLike): Promise<PermissionHttpResponse> {
|
||||
const body = await request.json();
|
||||
assertBodyObject(body);
|
||||
const actor = await resolveActor(request, body);
|
||||
const check = readCheck(body);
|
||||
const result = await runtime.explain({ actor, ...check });
|
||||
return { status: PERMISSION_HTTP_STATUS_OK, body: result };
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
export { createEnginePermissions } from './engine-permissions.ts';
|
||||
export { createActivePermissions } from './active-permissions.svelte.ts';
|
||||
export { createPermissionClient } from './client.ts';
|
||||
export { createPermissionHttpHandlers } from './http.ts';
|
||||
export { getPermissionsContext, setPermissionsContext } from './context.ts';
|
||||
export { permissionDecisionKey, stablePermissionStringify } from './keys.ts';
|
||||
|
||||
export * from './consts.ts';
|
||||
export * from './types.ts';
|
||||
|
||||
export {
|
||||
actionMatches,
|
||||
actionResource,
|
||||
actionsForResource,
|
||||
actor,
|
||||
allow,
|
||||
and,
|
||||
attr,
|
||||
audit,
|
||||
ctx,
|
||||
definePermSchema,
|
||||
definePolicies,
|
||||
deny,
|
||||
ExprBuilder,
|
||||
mask,
|
||||
not,
|
||||
or,
|
||||
PERMISSION_EFFECT_ALLOW,
|
||||
PERMISSION_EFFECT_DENY,
|
||||
PERMISSION_EFFECT_INDETERMINATE,
|
||||
PERMISSION_EFFECT_NOT_APPLICABLE,
|
||||
PERMISSION_QUERY_TARGET_SQL,
|
||||
PolicyBuilder,
|
||||
redact,
|
||||
rel,
|
||||
RelationBuilder,
|
||||
requireMfa,
|
||||
resource,
|
||||
resourceKey,
|
||||
val,
|
||||
createSqlCompiler
|
||||
} from '$libs/perm';
|
||||
|
||||
export type {
|
||||
CreateSqlCompilerOptions,
|
||||
SqlCompileResult,
|
||||
SqlRelationCompiler,
|
||||
SqlRelationCompilerInput
|
||||
} from '$libs/perm';
|
||||
@ -0,0 +1,30 @@
|
||||
import {
|
||||
PERMISSION_CLIENT_CONTEXT_EMPTY,
|
||||
PERMISSION_CLIENT_KEY_GLOBAL,
|
||||
PERMISSION_CLIENT_KEY_NONE,
|
||||
PERMISSION_CLIENT_KEY_SEPARATOR
|
||||
} from './consts.ts';
|
||||
import type { PermissionClientCheckInput } from './types.ts';
|
||||
|
||||
export function stablePermissionStringify(input: unknown): string {
|
||||
if (input === undefined) return PERMISSION_CLIENT_CONTEXT_EMPTY;
|
||||
if (input === null || typeof input !== 'object') return JSON.stringify(input);
|
||||
if (Array.isArray(input)) return `[${input.map(stablePermissionStringify).join(',')}]`;
|
||||
const sorted = Object.entries(input as Record<string, unknown>).sort(([a], [b]) =>
|
||||
a.localeCompare(b)
|
||||
);
|
||||
return `{${sorted
|
||||
.map(([key, value]) => `${JSON.stringify(key)}:${stablePermissionStringify(value)}`)
|
||||
.join(',')}}`;
|
||||
}
|
||||
|
||||
export function permissionDecisionKey(input: PermissionClientCheckInput): string {
|
||||
const resource = input.resource
|
||||
? `${input.resource.type}${PERMISSION_CLIENT_KEY_SEPARATOR}${input.resource.id ?? PERMISSION_CLIENT_KEY_NONE}`
|
||||
: PERMISSION_CLIENT_KEY_GLOBAL;
|
||||
return [
|
||||
resource,
|
||||
input.action,
|
||||
input.context ? stablePermissionStringify(input.context) : PERMISSION_CLIENT_CONTEXT_EMPTY
|
||||
].join(PERMISSION_CLIENT_KEY_SEPARATOR);
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { PERMISSION_EFFECT_ALLOW } from '$libs/perm';
|
||||
import {
|
||||
allow,
|
||||
attr,
|
||||
createEnginePermissions,
|
||||
createPermissionClient,
|
||||
createPermissionHttpHandlers,
|
||||
definePermSchema,
|
||||
definePolicies,
|
||||
permissionDecisionKey
|
||||
} from '$perm';
|
||||
|
||||
const schema = definePermSchema({
|
||||
actors: {
|
||||
user: { attributes: { status: 'string' } }
|
||||
},
|
||||
resources: {
|
||||
post: { actions: ['read'] }
|
||||
}
|
||||
});
|
||||
|
||||
const policies = definePolicies(schema, [
|
||||
allow('post.read').id('post.read.public').when(attr('post.visibility').eq('public'))
|
||||
]);
|
||||
|
||||
function jsonRequest(body: unknown) {
|
||||
return {
|
||||
method: 'POST',
|
||||
async json() {
|
||||
return body;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('Permission client + HTTP handlers', () => {
|
||||
it('checks remotely, caches the decision and keeps the same decision key as batch', async () => {
|
||||
const runtime = createEnginePermissions({ schema, policies });
|
||||
const actor = { type: 'user', id: 'u1', status: 'active' };
|
||||
const handlers = createPermissionHttpHandlers(runtime, () => actor);
|
||||
const calls: string[] = [];
|
||||
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
const path = new URL(url).pathname;
|
||||
calls.push(path);
|
||||
const body = JSON.parse(String(init?.body));
|
||||
const handler = path.endsWith('/batch') ? handlers.batch : handlers.check;
|
||||
const response = await handler(jsonRequest(body));
|
||||
return new Response(JSON.stringify(response.body), { status: response.status });
|
||||
}) as typeof fetch;
|
||||
|
||||
const client = createPermissionClient({
|
||||
endpoint: 'https://perm.test/permissions',
|
||||
fetcher
|
||||
});
|
||||
|
||||
const input = {
|
||||
action: 'post.read',
|
||||
resource: { type: 'post', id: 'p1', visibility: 'public' }
|
||||
};
|
||||
|
||||
const first = await client.check(input);
|
||||
const second = await client.check(input);
|
||||
|
||||
expect(first.effect).toBe(PERMISSION_EFFECT_ALLOW);
|
||||
expect(second.effect).toBe(PERMISSION_EFFECT_ALLOW);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
expect(calls).toEqual(['/permissions/check']);
|
||||
|
||||
client.invalidate();
|
||||
|
||||
const batch = await client.batch({ checks: [input] });
|
||||
expect(batch[permissionDecisionKey(input)]?.effect).toBe(PERMISSION_EFFECT_ALLOW);
|
||||
expect(calls).toEqual(['/permissions/check', '/permissions/batch']);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
PERMISSION_EFFECT_ALLOW,
|
||||
PERMISSION_EFFECT_DENY,
|
||||
PERMISSION_EFFECT_INDETERMINATE,
|
||||
PERMISSION_FALLBACK_DENY
|
||||
} from '$libs/perm';
|
||||
import {
|
||||
actor,
|
||||
allow,
|
||||
attr,
|
||||
createEnginePermissions,
|
||||
definePermSchema,
|
||||
definePolicies,
|
||||
deny,
|
||||
rel
|
||||
} from '$perm';
|
||||
|
||||
const schema = definePermSchema({
|
||||
actors: {
|
||||
user: { attributes: { status: 'string' } }
|
||||
},
|
||||
resources: {
|
||||
post: { actions: ['read', 'update'] }
|
||||
},
|
||||
relations: {
|
||||
'post.blocked': { from: 'post', to: 'user' },
|
||||
'post.owner': { from: 'post', to: 'user' }
|
||||
}
|
||||
});
|
||||
|
||||
const actorRef = { type: 'user', id: 'u1', status: 'active' };
|
||||
const publicPost = { type: 'post', id: 'p1', visibility: 'public', ownerId: 'u1' };
|
||||
|
||||
describe('EnginePermissions', () => {
|
||||
it('fails closed when a matching deny policy is indeterminate, even if allow matches', async () => {
|
||||
const policies = definePolicies(schema, [
|
||||
deny('post.*').id('post.deny.blocked').priority(100).when(rel('post.blocked').is(actor())),
|
||||
allow('post.read').id('post.read.public').when(attr('post.visibility').eq('public'))
|
||||
]);
|
||||
|
||||
const Perm = createEnginePermissions({
|
||||
schema,
|
||||
policies,
|
||||
providers: {
|
||||
relations: {
|
||||
hasRelation({ relation }) {
|
||||
if (relation === 'post.blocked') return 'unknown';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const decision = await Perm.check({
|
||||
actor: actorRef,
|
||||
action: 'post.read',
|
||||
resource: publicPost
|
||||
});
|
||||
|
||||
expect(decision.effect).toBe(PERMISSION_EFFECT_INDETERMINATE);
|
||||
if (decision.effect === PERMISSION_EFFECT_INDETERMINATE) {
|
||||
expect(decision.fallback).toBe(PERMISSION_FALLBACK_DENY);
|
||||
expect(decision.policy).toBe('post.deny.blocked');
|
||||
}
|
||||
await expect(
|
||||
Perm.can({ actor: actorRef, action: 'post.read', resource: publicPost })
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('keeps explicit deny above allow when both conditions are true', async () => {
|
||||
const policies = definePolicies(schema, [
|
||||
deny('post.read').id('post.deny.suspended').when(attr('actor.status').eq('suspended')),
|
||||
allow('post.read').id('post.read.public').when(attr('post.visibility').eq('public'))
|
||||
]);
|
||||
|
||||
const Perm = createEnginePermissions({ schema, policies });
|
||||
const decision = await Perm.check({
|
||||
actor: { ...actorRef, status: 'suspended' },
|
||||
action: 'post.read',
|
||||
resource: publicPost
|
||||
});
|
||||
|
||||
expect(decision.effect).toBe(PERMISSION_EFFECT_DENY);
|
||||
if (decision.effect === PERMISSION_EFFECT_DENY) {
|
||||
expect(decision.policy).toBe('post.deny.suspended');
|
||||
}
|
||||
});
|
||||
|
||||
it('answers what() with rich decisions per action', async () => {
|
||||
const policies = definePolicies(schema, [
|
||||
allow('post.read').id('post.read.public').when(attr('post.visibility').eq('public')),
|
||||
allow('post.update').id('post.update.owner').when(rel('post.owner').is(actor()))
|
||||
]);
|
||||
|
||||
const Perm = createEnginePermissions({
|
||||
schema,
|
||||
policies,
|
||||
providers: {
|
||||
relations: {
|
||||
hasRelation({ relation, resource, subject }) {
|
||||
if (relation === 'post.owner') return resource.ownerId === subject.id;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const result = await Perm.what({ actor: actorRef, resource: publicPost });
|
||||
|
||||
expect(result.actions['post.read']?.effect).toBe(PERMISSION_EFFECT_ALLOW);
|
||||
expect(result.actions['post.update']?.effect).toBe(PERMISSION_EFFECT_ALLOW);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,122 @@
|
||||
import type { EngineHttp } from '$http';
|
||||
import type { EngineLogger } from '$logr';
|
||||
import type {
|
||||
ExplainResult,
|
||||
PermSchema,
|
||||
PermissionCheckInput,
|
||||
PermissionDecision,
|
||||
PermissionRuntime,
|
||||
PermissionRuntimeOptions,
|
||||
PolicyIR,
|
||||
QueryCompiler,
|
||||
ResourceRef,
|
||||
SubjectRef
|
||||
} from '$libs/perm';
|
||||
|
||||
export type {
|
||||
AdviceIR,
|
||||
AttributeProvider,
|
||||
DependencyKey,
|
||||
ExplainResult,
|
||||
ExprIR,
|
||||
ObligationIR,
|
||||
PermSchema,
|
||||
PermissionCheckInput,
|
||||
PermissionDecision,
|
||||
PermissionEffect,
|
||||
PermissionFallback,
|
||||
PermissionFilterBuilder,
|
||||
PermissionProviders,
|
||||
PermissionRuntime,
|
||||
PermissionRuntimeOptions,
|
||||
PolicyIR,
|
||||
QueryCompiler,
|
||||
QueryPlan,
|
||||
RelationProvider,
|
||||
ResourceRef,
|
||||
ReverseQueryResult,
|
||||
SubjectRef
|
||||
} from '$libs/perm';
|
||||
|
||||
export interface EnginePermissionsOptions extends PermissionRuntimeOptions {
|
||||
readonly logger?: EngineLogger;
|
||||
}
|
||||
|
||||
export interface EnginePermissions extends PermissionRuntime {
|
||||
readonly schema: PermSchema;
|
||||
readonly policies: readonly PolicyIR[];
|
||||
readonly compilers: readonly QueryCompiler[];
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface PermissionSnapshot {
|
||||
readonly actor?: SubjectRef;
|
||||
readonly version?: string;
|
||||
readonly decisions?: Record<string, PermissionDecision>;
|
||||
readonly global?: Record<string, boolean | PermissionDecision>;
|
||||
readonly expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface PermissionClientOptions {
|
||||
readonly endpoint: string;
|
||||
readonly fetcher?: typeof fetch;
|
||||
readonly http?: EngineHttp;
|
||||
readonly initialSnapshot?: PermissionSnapshot;
|
||||
readonly cacheTtlMs?: number;
|
||||
readonly logger?: EngineLogger;
|
||||
readonly onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface PermissionClientCheckInput {
|
||||
readonly action: string;
|
||||
readonly resource?: ResourceRef;
|
||||
readonly context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PermissionClientBatchInput {
|
||||
readonly checks: readonly PermissionClientCheckInput[];
|
||||
}
|
||||
|
||||
export interface PermissionClient {
|
||||
check(input: PermissionClientCheckInput): Promise<PermissionDecision>;
|
||||
can(input: PermissionClientCheckInput): Promise<boolean>;
|
||||
batch(input: PermissionClientBatchInput): Promise<Record<string, PermissionDecision>>;
|
||||
what(input: {
|
||||
readonly resource?: ResourceRef;
|
||||
readonly actions?: readonly string[];
|
||||
readonly context?: Record<string, unknown>;
|
||||
}): Promise<Record<string, PermissionDecision>>;
|
||||
explain(input: PermissionClientCheckInput): Promise<ExplainResult | null>;
|
||||
hydrate(snapshot: PermissionSnapshot): void;
|
||||
snapshot(): PermissionSnapshot;
|
||||
invalidate(scope?: string): void;
|
||||
subscribe(listener: (snapshot: PermissionSnapshot) => void): () => void;
|
||||
decisionKey(input: PermissionClientCheckInput): string;
|
||||
}
|
||||
|
||||
export interface ActivePermissions extends PermissionClient {
|
||||
readonly currentSnapshot: PermissionSnapshot;
|
||||
readonly decisions: Record<string, PermissionDecision>;
|
||||
readonly size: number;
|
||||
readonly loading: boolean;
|
||||
readonly lastError: unknown | null;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export type ActivePermissionsOptions = PermissionClientOptions;
|
||||
|
||||
export interface PermissionHttpRequestLike {
|
||||
readonly method: string;
|
||||
readonly url?: string;
|
||||
json(): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface PermissionHttpResponse {
|
||||
readonly status: number;
|
||||
readonly body: unknown;
|
||||
}
|
||||
|
||||
export type PermissionActorResolver = (
|
||||
request: PermissionHttpRequestLike,
|
||||
body: unknown
|
||||
) => Promise<PermissionCheckInput['actor']> | PermissionCheckInput['actor'];
|
||||
@ -1,34 +1 @@
|
||||
/**
|
||||
* Decode a JWT's `exp` claim and return it as **epoch milliseconds**, or
|
||||
* `null` when the token is malformed / missing the claim. **No signature
|
||||
* verification, no claim validation** — trust this only for scheduling
|
||||
* (compute when to refresh), never for authorization decisions.
|
||||
*
|
||||
* Opt-in helper, separate file. The artifact does not bundle a JWT
|
||||
* library; consumers that store opaque tokens never reach this code.
|
||||
*
|
||||
* @example
|
||||
* import { extractJwtExp } from '$sess/jwt';
|
||||
*
|
||||
* await Sess.adopt({
|
||||
* user,
|
||||
* credential: { accessToken, refreshToken },
|
||||
* issuedAt: Date.now(),
|
||||
* expiresAt: extractJwtExp(accessToken) ?? Date.now() + 3_600_000
|
||||
* });
|
||||
*/
|
||||
export function extractJwtExp(token: string): number | null {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
try {
|
||||
const payload = parts[1];
|
||||
const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4);
|
||||
const json = atob(padded.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
const claims = JSON.parse(json) as { exp?: unknown };
|
||||
if (typeof claims.exp === 'number') return claims.exp * 1000;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export { extractJwtExp } from '$libs/auth/jwt';
|
||||
|
||||
@ -1,52 +1,2 @@
|
||||
import {
|
||||
DEFAULT_BACKOFF_FACTOR,
|
||||
DEFAULT_BACKOFF_JITTER_MS,
|
||||
DEFAULT_BACKOFF_MAX_DELAY_MS,
|
||||
DEFAULT_BACKOFF_MIN_DELAY_MS
|
||||
} from './consts.ts';
|
||||
import type { BackoffOptions } from './types.ts';
|
||||
|
||||
/**
|
||||
* Exponential backoff with full jitter, returning a delay in
|
||||
* milliseconds for the given attempt index.
|
||||
*
|
||||
* Pure function — no scheduling, no side effects. Pair with
|
||||
* `EngineTimers.schedule(...)` when you actually want to wait.
|
||||
*
|
||||
* Algorithm:
|
||||
*
|
||||
* base = min(maxDelayMs, minDelayMs * factor ** attempt)
|
||||
* jitter = uniform(-jitterMs, +jitterMs)
|
||||
* delay = clamp(0, maxDelayMs, base + jitter)
|
||||
*
|
||||
* `attempt` is **0-indexed** — the first try uses `minDelayMs` (± jitter).
|
||||
*
|
||||
* `random` is injectable so tests can pin the result.
|
||||
*
|
||||
* @example
|
||||
* for (let attempt = 0; ; attempt++) {
|
||||
* const ok = await tryConnect();
|
||||
* if (ok) break;
|
||||
* await new Promise(r => setTimeout(r, computeBackoffDelay(attempt)));
|
||||
* }
|
||||
*/
|
||||
export function computeBackoffDelay(
|
||||
attempt: number,
|
||||
options: BackoffOptions = {},
|
||||
random: () => number = Math.random
|
||||
): number {
|
||||
const minDelayMs = options.minDelayMs ?? DEFAULT_BACKOFF_MIN_DELAY_MS;
|
||||
const maxDelayMs = options.maxDelayMs ?? DEFAULT_BACKOFF_MAX_DELAY_MS;
|
||||
const factor = options.factor ?? DEFAULT_BACKOFF_FACTOR;
|
||||
const jitterMs = options.jitterMs ?? DEFAULT_BACKOFF_JITTER_MS;
|
||||
|
||||
const safeAttempt = Number.isFinite(attempt) && attempt >= 0 ? attempt : 0;
|
||||
const base = Math.min(maxDelayMs, minDelayMs * Math.pow(factor, safeAttempt));
|
||||
// random ∈ [0, 1) → uniform in [-jitterMs, +jitterMs)
|
||||
const jitter = jitterMs > 0 ? (random() * 2 - 1) * jitterMs : 0;
|
||||
|
||||
const delay = base + jitter;
|
||||
if (delay < 0) return 0;
|
||||
if (delay > maxDelayMs) return maxDelayMs;
|
||||
return delay;
|
||||
}
|
||||
export { computeBackoffDelay } from '$libs/timers';
|
||||
export type { BackoffOptions } from '$libs/timers';
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Decode a JWT's `exp` claim and return it as **epoch milliseconds**, or
|
||||
* `null` when the token is malformed / missing the claim. **No signature
|
||||
* verification, no claim validation** — trust this only for scheduling
|
||||
* (compute when to refresh), never for authorization decisions.
|
||||
*
|
||||
* Opt-in helper, separate file. The artifact does not bundle a JWT
|
||||
* library; consumers that store opaque tokens never reach this code.
|
||||
*
|
||||
* @example
|
||||
* import { extractJwtExp } from '$sess/jwt';
|
||||
*
|
||||
* await Sess.adopt({
|
||||
* user,
|
||||
* accessToken,
|
||||
* expiresAt: extractJwtExp(accessToken) ?? Date.now() + 3_600_000
|
||||
* });
|
||||
*/
|
||||
export function extractJwtExp(token: string): number | null {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
try {
|
||||
const payload = parts[1];
|
||||
const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4);
|
||||
const json = atob(padded.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
const claims = JSON.parse(json) as { exp?: unknown };
|
||||
if (typeof claims.exp === 'number') return claims.exp * 1000;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,102 @@
|
||||
import {
|
||||
HTTP_CONTENT_TYPE_JSON,
|
||||
HTTP_HEADER_CONTENT_TYPE,
|
||||
HTTP_METHOD_HEAD,
|
||||
HTTP_NULL_BODY_STATUSES
|
||||
} from './consts';
|
||||
import type { HttpBodyInit, HttpHeadersHook, HttpHeadersInit } from './types';
|
||||
|
||||
const JSON_CONTENT_TYPE_PATTERN = /\/(?:.*[.+-])?json(?:;|$)/i;
|
||||
|
||||
export function isJsonContentType(value: string): boolean {
|
||||
return JSON_CONTENT_TYPE_PATTERN.test(value);
|
||||
}
|
||||
|
||||
export function isJSONSerializable(value: unknown): value is Record<string, unknown> | unknown[] {
|
||||
if (value === null || value === undefined) return false;
|
||||
if (typeof value !== 'object') return false;
|
||||
if (Array.isArray(value)) return true;
|
||||
|
||||
const obj = value as { buffer?: unknown; constructor?: { name?: string }; toJSON?: unknown };
|
||||
|
||||
if (obj.buffer !== undefined && obj.buffer instanceof ArrayBuffer) return false;
|
||||
if (
|
||||
value instanceof FormData ||
|
||||
value instanceof URLSearchParams ||
|
||||
value instanceof Blob ||
|
||||
value instanceof ArrayBuffer ||
|
||||
(typeof ReadableStream !== 'undefined' && value instanceof ReadableStream)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ctor = obj.constructor?.name;
|
||||
if (ctor === 'Object' || ctor === undefined) return true;
|
||||
if (typeof obj.toJSON === 'function') return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function serializeBody(body: HttpBodyInit): {
|
||||
body: BodyInit | undefined;
|
||||
contentType?: string;
|
||||
} {
|
||||
if (body === null || body === undefined) return { body: undefined };
|
||||
if (isJSONSerializable(body)) {
|
||||
return { body: JSON.stringify(body), contentType: HTTP_CONTENT_TYPE_JSON };
|
||||
}
|
||||
return { body: body as BodyInit };
|
||||
}
|
||||
|
||||
export async function parseBody(response: Response, method: string): Promise<unknown> {
|
||||
if (HTTP_NULL_BODY_STATUSES.has(response.status)) return undefined;
|
||||
if (method.toUpperCase() === HTTP_METHOD_HEAD) return undefined;
|
||||
|
||||
const contentType = response.headers.get(HTTP_HEADER_CONTENT_TYPE) ?? '';
|
||||
|
||||
if (isJsonContentType(contentType)) {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await response.text();
|
||||
return text === '' ? undefined : text;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function mergeHeaders(
|
||||
defaults: HttpHeadersInit | HttpHeadersHook | undefined,
|
||||
override: HttpHeadersInit | HttpHeadersHook | undefined
|
||||
): Promise<Headers> {
|
||||
const result = new Headers();
|
||||
|
||||
if (defaults !== undefined) await applyHeaders(result, defaults);
|
||||
if (override !== undefined) await applyHeaders(result, override);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function applyHeaders(
|
||||
target: Headers,
|
||||
source: HttpHeadersInit | HttpHeadersHook
|
||||
): Promise<void> {
|
||||
const resolved = typeof source === 'function' ? await source() : source;
|
||||
|
||||
if (resolved instanceof Headers) {
|
||||
resolved.forEach((value, key) => target.set(key, value));
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(resolved)) {
|
||||
for (const [key, value] of resolved) target.set(key, value);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(resolved)) target.set(key, value);
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
export const HTTP_METHOD_GET = 'GET';
|
||||
export const HTTP_METHOD_HEAD = 'HEAD';
|
||||
export const HTTP_METHOD_POST = 'POST';
|
||||
export const HTTP_METHOD_PUT = 'PUT';
|
||||
export const HTTP_METHOD_PATCH = 'PATCH';
|
||||
export const HTTP_METHOD_DELETE = 'DELETE';
|
||||
export const HTTP_METHOD_OPTIONS = 'OPTIONS';
|
||||
|
||||
export const HTTP_METHODS = [
|
||||
HTTP_METHOD_GET,
|
||||
HTTP_METHOD_HEAD,
|
||||
HTTP_METHOD_POST,
|
||||
HTTP_METHOD_PUT,
|
||||
HTTP_METHOD_PATCH,
|
||||
HTTP_METHOD_DELETE,
|
||||
HTTP_METHOD_OPTIONS
|
||||
] as const;
|
||||
|
||||
export const HTTP_SAFE_METHODS = [HTTP_METHOD_GET, HTTP_METHOD_HEAD, HTTP_METHOD_OPTIONS] as const;
|
||||
|
||||
export const HTTP_IDEMPOTENT_METHODS = [
|
||||
HTTP_METHOD_GET,
|
||||
HTTP_METHOD_HEAD,
|
||||
HTTP_METHOD_PUT,
|
||||
HTTP_METHOD_DELETE,
|
||||
HTTP_METHOD_OPTIONS
|
||||
] as const;
|
||||
|
||||
export const HTTP_BODY_METHODS = [HTTP_METHOD_POST, HTTP_METHOD_PUT, HTTP_METHOD_PATCH] as const;
|
||||
|
||||
export const HTTP_HEADER_ACCEPT = 'accept';
|
||||
export const HTTP_HEADER_AUTHORIZATION = 'authorization';
|
||||
export const HTTP_HEADER_CONTENT_TYPE = 'content-type';
|
||||
export const HTTP_HEADER_RETRY_AFTER = 'retry-after';
|
||||
export const HTTP_HEADER_RATELIMIT_RESET = 'ratelimit-reset';
|
||||
export const HTTP_HEADER_X_RATELIMIT_RESET = 'x-ratelimit-reset';
|
||||
export const HTTP_HEADER_X_RATE_LIMIT_RESET = 'x-rate-limit-reset';
|
||||
export const HTTP_HEADER_X_RATELIMIT_RETRY_AFTER = 'x-ratelimit-retry-after';
|
||||
|
||||
export const HTTP_RETRY_AFTER_HEADERS = [
|
||||
HTTP_HEADER_RETRY_AFTER,
|
||||
HTTP_HEADER_RATELIMIT_RESET,
|
||||
HTTP_HEADER_X_RATELIMIT_RESET,
|
||||
HTTP_HEADER_X_RATE_LIMIT_RESET,
|
||||
HTTP_HEADER_X_RATELIMIT_RETRY_AFTER
|
||||
] as const;
|
||||
|
||||
export const HTTP_CONTENT_TYPE_JSON = 'application/json';
|
||||
|
||||
export const HTTP_STATUS_SWITCHING_PROTOCOLS = 101;
|
||||
export const HTTP_STATUS_NO_CONTENT = 204;
|
||||
export const HTTP_STATUS_RESET_CONTENT = 205;
|
||||
export const HTTP_STATUS_NOT_MODIFIED = 304;
|
||||
export const HTTP_STATUS_BAD_REQUEST = 400;
|
||||
export const HTTP_STATUS_UNAUTHORIZED = 401;
|
||||
export const HTTP_STATUS_FORBIDDEN = 403;
|
||||
export const HTTP_STATUS_NOT_FOUND = 404;
|
||||
export const HTTP_STATUS_REQUEST_TIMEOUT = 408;
|
||||
export const HTTP_STATUS_TOO_EARLY = 425;
|
||||
export const HTTP_STATUS_TOO_MANY_REQUESTS = 429;
|
||||
export const HTTP_STATUS_INTERNAL_SERVER_ERROR = 500;
|
||||
export const HTTP_STATUS_BAD_GATEWAY = 502;
|
||||
export const HTTP_STATUS_SERVICE_UNAVAILABLE = 503;
|
||||
export const HTTP_STATUS_GATEWAY_TIMEOUT = 504;
|
||||
|
||||
export const HTTP_NULL_BODY_STATUSES: ReadonlySet<number> = new Set([
|
||||
HTTP_STATUS_SWITCHING_PROTOCOLS,
|
||||
HTTP_STATUS_NO_CONTENT,
|
||||
HTTP_STATUS_RESET_CONTENT,
|
||||
HTTP_STATUS_NOT_MODIFIED
|
||||
]);
|
||||
|
||||
export const HTTP_DEFAULT_RETRY_STATUSES = [
|
||||
HTTP_STATUS_REQUEST_TIMEOUT,
|
||||
HTTP_STATUS_TOO_EARLY,
|
||||
HTTP_STATUS_TOO_MANY_REQUESTS,
|
||||
HTTP_STATUS_INTERNAL_SERVER_ERROR,
|
||||
HTTP_STATUS_BAD_GATEWAY,
|
||||
HTTP_STATUS_SERVICE_UNAVAILABLE,
|
||||
HTTP_STATUS_GATEWAY_TIMEOUT
|
||||
] as const;
|
||||
|
||||
export const RETRY_AFTER_DATE_CEILING_SECONDS = 1_700_000_000;
|
||||
@ -0,0 +1,5 @@
|
||||
export * from './consts';
|
||||
export * from './types';
|
||||
export * from './body';
|
||||
export * from './search';
|
||||
export * from './retry';
|
||||
@ -0,0 +1,24 @@
|
||||
import { HTTP_RETRY_AFTER_HEADERS, RETRY_AFTER_DATE_CEILING_SECONDS } from './consts';
|
||||
|
||||
export function parseRetryAfter(headers: Headers, now: number = Date.now()): number | undefined {
|
||||
for (const name of HTTP_RETRY_AFTER_HEADERS) {
|
||||
const raw = headers.get(name);
|
||||
if (raw === null) continue;
|
||||
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') continue;
|
||||
|
||||
const numeric = Number(trimmed);
|
||||
if (Number.isFinite(numeric)) {
|
||||
if (numeric < 0) return 0;
|
||||
if (numeric > RETRY_AFTER_DATE_CEILING_SECONDS) {
|
||||
return Math.max(0, numeric * 1000 - now);
|
||||
}
|
||||
return numeric * 1000;
|
||||
}
|
||||
|
||||
const parsed = Date.parse(trimmed);
|
||||
if (Number.isFinite(parsed)) return Math.max(0, parsed - now);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
import type { HttpSearchInit } from './types';
|
||||
|
||||
export function normalizeSearch(input: HttpSearchInit | undefined): URLSearchParams | undefined {
|
||||
if (input === undefined) return undefined;
|
||||
|
||||
if (input instanceof URLSearchParams) return new URLSearchParams(input);
|
||||
|
||||
if (typeof input === 'string') {
|
||||
return new URLSearchParams(input.startsWith('?') ? input.slice(1) : input);
|
||||
}
|
||||
|
||||
const out = new URLSearchParams();
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
for (const [key, value] of input) out.append(key, String(value));
|
||||
return out;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (value === null || value === undefined) continue;
|
||||
out.append(key, String(value));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function appendSearch(url: string, params: URLSearchParams | undefined): string {
|
||||
if (params === undefined || params.size === 0) return url;
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return `${url}${separator}${params.toString()}`;
|
||||
}
|
||||
|
||||
export function resolveUrl(url: string, baseUrl: string | undefined): string {
|
||||
if (baseUrl === undefined || baseUrl === '') return url;
|
||||
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(url)) return url;
|
||||
|
||||
const trimmedBase = baseUrl.split('?')[0].split('#')[0].replace(/\/+$/, '');
|
||||
const trimmedUrl = url.replace(/^\/+/, '');
|
||||
return `${trimmedBase}/${trimmedUrl}`;
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
import type { HTTP_METHODS } from './consts';
|
||||
|
||||
export type HttpMethod = (typeof HTTP_METHODS)[number];
|
||||
|
||||
export type HttpSearchInit =
|
||||
| string
|
||||
| URLSearchParams
|
||||
| Record<string, string | number | boolean | null | undefined>
|
||||
| ReadonlyArray<readonly [string, string | number | boolean]>;
|
||||
|
||||
export type HttpHeadersInit =
|
||||
| Headers
|
||||
| Record<string, string>
|
||||
| ReadonlyArray<readonly [string, string]>;
|
||||
|
||||
export type HttpHeadersHook = () => HttpHeadersInit | Promise<HttpHeadersInit>;
|
||||
|
||||
export type HttpBodyInit = BodyInit | Record<string, unknown> | unknown[] | null | undefined;
|
||||
@ -0,0 +1,328 @@
|
||||
import {
|
||||
PERMISSION_EFFECT_ALLOW,
|
||||
PERMISSION_EFFECT_DENY,
|
||||
PERMISSION_ACTION_WILDCARD_SUFFIX,
|
||||
PERMISSION_ERROR_MSG_MFA_REQUIRED,
|
||||
PERMISSION_ERROR_MSG_POLICY_UNKNOWN_ACTION_PREFIX,
|
||||
PERMISSION_ERROR_MSG_POLICY_UNKNOWN_RESOURCE_PREFIX,
|
||||
PERMISSION_EXPR_AND,
|
||||
PERMISSION_EXPR_CONST,
|
||||
PERMISSION_EXPR_CONTAINS,
|
||||
PERMISSION_EXPR_EQ,
|
||||
PERMISSION_EXPR_GT,
|
||||
PERMISSION_EXPR_GTE,
|
||||
PERMISSION_EXPR_IN,
|
||||
PERMISSION_EXPR_LT,
|
||||
PERMISSION_EXPR_LTE,
|
||||
PERMISSION_EXPR_NEQ,
|
||||
PERMISSION_EXPR_NOT,
|
||||
PERMISSION_EXPR_OR,
|
||||
PERMISSION_EXPR_REF,
|
||||
PERMISSION_EXPR_REL,
|
||||
PERMISSION_MASK_MODE_FULL,
|
||||
PERMISSION_OBLIGATION_AUDIT,
|
||||
PERMISSION_OBLIGATION_MASK,
|
||||
PERMISSION_OBLIGATION_REDACT,
|
||||
PERMISSION_OBLIGATION_REQUIRE_MFA,
|
||||
PERMISSION_PATH_SEPARATOR,
|
||||
PERMISSION_POLICY_DEFAULT_PRIORITY,
|
||||
PERMISSION_ROOT_ACTOR,
|
||||
PERMISSION_ROOT_CONTEXT,
|
||||
PERMISSION_ROOT_RESOURCE,
|
||||
PERMISSION_SEVERITY_HIGH,
|
||||
PERMISSION_SEVERITY_LOW,
|
||||
PERMISSION_SEVERITY_MEDIUM,
|
||||
permissionAutoPolicyId
|
||||
} from './consts.ts';
|
||||
import { PermissionSchemaError } from './errors.ts';
|
||||
import { stripResourcePrefix } from './path.ts';
|
||||
import { actionResource } from './schema.ts';
|
||||
import type { AdviceIR, ExprIR, ObligationIR, PermSchema, PolicyIR } from './types.ts';
|
||||
|
||||
let autoPolicyCounter = 0;
|
||||
|
||||
type ExprInput =
|
||||
| ExprBuilder
|
||||
| ExprIR
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| undefined
|
||||
| Record<string, unknown>
|
||||
| readonly unknown[];
|
||||
|
||||
function toIR(input: ExprInput): ExprIR {
|
||||
if (input instanceof ExprBuilder) return input.ir;
|
||||
if (isExprIR(input)) return input;
|
||||
return { op: PERMISSION_EXPR_CONST, value: input };
|
||||
}
|
||||
|
||||
function isExprIR(input: unknown): input is ExprIR {
|
||||
return Boolean(input && typeof input === 'object' && 'op' in input);
|
||||
}
|
||||
|
||||
function refForAttribute(path: string): ExprIR {
|
||||
const parts = path.split(PERMISSION_PATH_SEPARATOR).filter(Boolean);
|
||||
if (parts[0] === PERMISSION_ROOT_ACTOR) {
|
||||
return {
|
||||
op: PERMISSION_EXPR_REF,
|
||||
root: PERMISSION_ROOT_ACTOR,
|
||||
path: parts.slice(1).join(PERMISSION_PATH_SEPARATOR)
|
||||
};
|
||||
}
|
||||
if (parts[0] === PERMISSION_ROOT_CONTEXT) {
|
||||
return {
|
||||
op: PERMISSION_EXPR_REF,
|
||||
root: PERMISSION_ROOT_CONTEXT,
|
||||
path: parts.slice(1).join(PERMISSION_PATH_SEPARATOR)
|
||||
};
|
||||
}
|
||||
if (parts[0] === PERMISSION_ROOT_RESOURCE) {
|
||||
return {
|
||||
op: PERMISSION_EXPR_REF,
|
||||
root: PERMISSION_ROOT_RESOURCE,
|
||||
path: parts.slice(1).join(PERMISSION_PATH_SEPARATOR)
|
||||
};
|
||||
}
|
||||
return {
|
||||
op: PERMISSION_EXPR_REF,
|
||||
root: PERMISSION_ROOT_RESOURCE,
|
||||
path: stripResourcePrefix(path)
|
||||
};
|
||||
}
|
||||
|
||||
export class ExprBuilder {
|
||||
constructor(readonly ir: ExprIR) {}
|
||||
|
||||
eq(value: ExprInput): ExprBuilder {
|
||||
return new ExprBuilder({ op: PERMISSION_EXPR_EQ, left: this.ir, right: toIR(value) });
|
||||
}
|
||||
|
||||
notEq(value: ExprInput): ExprBuilder {
|
||||
return new ExprBuilder({ op: PERMISSION_EXPR_NEQ, left: this.ir, right: toIR(value) });
|
||||
}
|
||||
|
||||
gt(value: ExprInput): ExprBuilder {
|
||||
return new ExprBuilder({ op: PERMISSION_EXPR_GT, left: this.ir, right: toIR(value) });
|
||||
}
|
||||
|
||||
gte(value: ExprInput): ExprBuilder {
|
||||
return new ExprBuilder({ op: PERMISSION_EXPR_GTE, left: this.ir, right: toIR(value) });
|
||||
}
|
||||
|
||||
lt(value: ExprInput): ExprBuilder {
|
||||
return new ExprBuilder({ op: PERMISSION_EXPR_LT, left: this.ir, right: toIR(value) });
|
||||
}
|
||||
|
||||
lte(value: ExprInput): ExprBuilder {
|
||||
return new ExprBuilder({ op: PERMISSION_EXPR_LTE, left: this.ir, right: toIR(value) });
|
||||
}
|
||||
|
||||
in(set: ExprInput): ExprBuilder {
|
||||
return new ExprBuilder({ op: PERMISSION_EXPR_IN, value: this.ir, set: toIR(set) });
|
||||
}
|
||||
|
||||
contains(value: ExprInput): ExprBuilder {
|
||||
return new ExprBuilder({ op: PERMISSION_EXPR_CONTAINS, set: this.ir, value: toIR(value) });
|
||||
}
|
||||
|
||||
not(): ExprBuilder {
|
||||
return new ExprBuilder({ op: PERMISSION_EXPR_NOT, expr: this.ir });
|
||||
}
|
||||
}
|
||||
|
||||
export class RelationBuilder {
|
||||
constructor(private readonly path: string) {}
|
||||
|
||||
has(subject: ExprInput): ExprBuilder {
|
||||
return new ExprBuilder({ op: PERMISSION_EXPR_REL, path: this.path, subject: toIR(subject) });
|
||||
}
|
||||
|
||||
is(subject: ExprInput): ExprBuilder {
|
||||
return this.has(subject);
|
||||
}
|
||||
}
|
||||
|
||||
export class PolicyBuilder {
|
||||
private conditionIR: ExprIR = { op: PERMISSION_EXPR_CONST, value: true };
|
||||
private policyId: string | undefined;
|
||||
private policyPriority = PERMISSION_POLICY_DEFAULT_PRIORITY;
|
||||
private policyReason: string | undefined;
|
||||
private policyCode: string | undefined;
|
||||
private policyObligations: ObligationIR[] = [];
|
||||
private policyAdvice: AdviceIR[] = [];
|
||||
private policyMetadata: Record<string, unknown> | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly policyEffect: typeof PERMISSION_EFFECT_ALLOW | typeof PERMISSION_EFFECT_DENY,
|
||||
private readonly policyAction: string
|
||||
) {}
|
||||
|
||||
id(id: string): this {
|
||||
this.policyId = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
priority(priority: number): this {
|
||||
this.policyPriority = priority;
|
||||
return this;
|
||||
}
|
||||
|
||||
when(expr: ExprInput): this {
|
||||
this.conditionIR = toIR(expr);
|
||||
return this;
|
||||
}
|
||||
|
||||
because(reason: string, code?: string): this {
|
||||
this.policyReason = reason;
|
||||
this.policyCode = code;
|
||||
return this;
|
||||
}
|
||||
|
||||
oblige(...obligations: ObligationIR[]): this {
|
||||
this.policyObligations.push(...obligations);
|
||||
return this;
|
||||
}
|
||||
|
||||
advise(...advice: AdviceIR[]): this {
|
||||
this.policyAdvice.push(...advice);
|
||||
return this;
|
||||
}
|
||||
|
||||
meta(metadata: Record<string, unknown>): this {
|
||||
this.policyMetadata = metadata;
|
||||
return this;
|
||||
}
|
||||
|
||||
build(): PolicyIR {
|
||||
const resource = actionResource(this.policyAction);
|
||||
const id =
|
||||
this.policyId ??
|
||||
permissionAutoPolicyId(this.policyEffect, this.policyAction, ++autoPolicyCounter);
|
||||
|
||||
const policy: PolicyIR = {
|
||||
id,
|
||||
effect: this.policyEffect,
|
||||
priority: this.policyPriority,
|
||||
target: resource ? { action: this.policyAction, resource } : { action: this.policyAction },
|
||||
condition: this.conditionIR
|
||||
};
|
||||
|
||||
if (this.policyReason !== undefined) (policy as { reason?: string }).reason = this.policyReason;
|
||||
if (this.policyCode !== undefined) (policy as { code?: string }).code = this.policyCode;
|
||||
if (this.policyObligations.length) {
|
||||
(policy as { obligations?: readonly ObligationIR[] }).obligations = this.policyObligations;
|
||||
}
|
||||
if (this.policyAdvice.length) {
|
||||
(policy as { advice?: readonly AdviceIR[] }).advice = this.policyAdvice;
|
||||
}
|
||||
if (this.policyMetadata !== undefined) {
|
||||
(policy as { metadata?: Record<string, unknown> }).metadata = this.policyMetadata;
|
||||
}
|
||||
|
||||
return policy;
|
||||
}
|
||||
}
|
||||
|
||||
export function allow(action: string): PolicyBuilder {
|
||||
return new PolicyBuilder(PERMISSION_EFFECT_ALLOW, action);
|
||||
}
|
||||
|
||||
export function deny(action: string): PolicyBuilder {
|
||||
return new PolicyBuilder(PERMISSION_EFFECT_DENY, action);
|
||||
}
|
||||
|
||||
export function attr(path: string): ExprBuilder {
|
||||
return new ExprBuilder(refForAttribute(path));
|
||||
}
|
||||
|
||||
export function ctx(path: string): ExprBuilder {
|
||||
return new ExprBuilder({ op: PERMISSION_EXPR_REF, root: PERMISSION_ROOT_CONTEXT, path });
|
||||
}
|
||||
|
||||
export function actor(path = ''): ExprBuilder {
|
||||
return new ExprBuilder({ op: PERMISSION_EXPR_REF, root: PERMISSION_ROOT_ACTOR, path });
|
||||
}
|
||||
|
||||
export function resource(path = ''): ExprBuilder {
|
||||
return new ExprBuilder({ op: PERMISSION_EXPR_REF, root: PERMISSION_ROOT_RESOURCE, path });
|
||||
}
|
||||
|
||||
export function val(value: unknown): ExprBuilder {
|
||||
return new ExprBuilder({ op: PERMISSION_EXPR_CONST, value });
|
||||
}
|
||||
|
||||
export function rel(path: string): RelationBuilder {
|
||||
return new RelationBuilder(path);
|
||||
}
|
||||
|
||||
export function and(...args: ExprInput[]): ExprBuilder {
|
||||
return new ExprBuilder({ op: PERMISSION_EXPR_AND, args: args.map(toIR) });
|
||||
}
|
||||
|
||||
export function or(...args: ExprInput[]): ExprBuilder {
|
||||
return new ExprBuilder({ op: PERMISSION_EXPR_OR, args: args.map(toIR) });
|
||||
}
|
||||
|
||||
export function not(expr: ExprInput): ExprBuilder {
|
||||
return new ExprBuilder({ op: PERMISSION_EXPR_NOT, expr: toIR(expr) });
|
||||
}
|
||||
|
||||
export function mask(field: string, mode: string = PERMISSION_MASK_MODE_FULL): ObligationIR {
|
||||
return { type: PERMISSION_OBLIGATION_MASK, field, mode };
|
||||
}
|
||||
|
||||
export function redact(field: string): ObligationIR {
|
||||
return { type: PERMISSION_OBLIGATION_REDACT, field };
|
||||
}
|
||||
|
||||
export function requireMfa(reason = PERMISSION_ERROR_MSG_MFA_REQUIRED): ObligationIR {
|
||||
return { type: PERMISSION_OBLIGATION_REQUIRE_MFA, reason };
|
||||
}
|
||||
|
||||
export function audit(
|
||||
event: string,
|
||||
severity:
|
||||
| typeof PERMISSION_SEVERITY_LOW
|
||||
| typeof PERMISSION_SEVERITY_MEDIUM
|
||||
| typeof PERMISSION_SEVERITY_HIGH = PERMISSION_SEVERITY_LOW
|
||||
): ObligationIR {
|
||||
return { type: PERMISSION_OBLIGATION_AUDIT, event, severity };
|
||||
}
|
||||
|
||||
export function definePolicies(
|
||||
schema: PermSchema,
|
||||
policies: readonly (PolicyBuilder | PolicyIR)[]
|
||||
): PolicyIR[] {
|
||||
const built = policies.map((policy) =>
|
||||
policy instanceof PolicyBuilder ? policy.build() : policy
|
||||
);
|
||||
validatePolicies(schema, built);
|
||||
return built;
|
||||
}
|
||||
|
||||
export function validatePolicies(schema: PermSchema, policies: readonly PolicyIR[]): void {
|
||||
for (const policy of policies) {
|
||||
const resource = policy.target.resource ?? actionResource(policy.target.action);
|
||||
if (resource !== undefined && schema.resources[resource] === undefined) {
|
||||
throw new PermissionSchemaError(
|
||||
`${PERMISSION_ERROR_MSG_POLICY_UNKNOWN_RESOURCE_PREFIX}${policy.id}`
|
||||
);
|
||||
}
|
||||
const action = policy.target.action.includes(PERMISSION_PATH_SEPARATOR)
|
||||
? policy.target.action.split(PERMISSION_PATH_SEPARATOR)[1]
|
||||
: policy.target.action;
|
||||
if (
|
||||
resource !== undefined &&
|
||||
!policy.target.action.endsWith(PERMISSION_ACTION_WILDCARD_SUFFIX) &&
|
||||
!schema.resources[resource]?.actions.includes(action)
|
||||
) {
|
||||
throw new PermissionSchemaError(
|
||||
`${PERMISSION_ERROR_MSG_POLICY_UNKNOWN_ACTION_PREFIX}${policy.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type { ExprInput };
|
||||
@ -0,0 +1,143 @@
|
||||
import {
|
||||
PERMISSION_DECISION_CODE_DENIED,
|
||||
PERMISSION_EFFECT_ALLOW,
|
||||
PERMISSION_EFFECT_DENY,
|
||||
PERMISSION_EFFECT_INDETERMINATE,
|
||||
PERMISSION_EFFECT_NOT_APPLICABLE,
|
||||
PERMISSION_ERROR_MSG_DENY_INDETERMINATE,
|
||||
PERMISSION_ERROR_MSG_EVALUATION_FAILED_DECISION,
|
||||
PERMISSION_ERROR_MSG_EVALUATION_UNKNOWN_DECISION,
|
||||
PERMISSION_ERROR_MSG_NOT_APPLICABLE,
|
||||
PERMISSION_EVAL_ERROR,
|
||||
PERMISSION_EVAL_UNKNOWN,
|
||||
PERMISSION_FALLBACK_DENY,
|
||||
permissionDeniedByPolicyReason
|
||||
} from './consts.ts';
|
||||
import type {
|
||||
EvalResult,
|
||||
PermissionDecision,
|
||||
PermissionFallback,
|
||||
PolicyIR,
|
||||
TraceEntry
|
||||
} from './types.ts';
|
||||
|
||||
export interface EvaluatedPolicy {
|
||||
readonly policy: PolicyIR;
|
||||
readonly targetMatched: boolean;
|
||||
readonly evaluation?: EvalResult;
|
||||
}
|
||||
|
||||
function byPriorityDesc(a: EvaluatedPolicy, b: EvaluatedPolicy): number {
|
||||
return b.policy.priority - a.policy.priority;
|
||||
}
|
||||
|
||||
function matched(entry: EvaluatedPolicy): boolean {
|
||||
return entry.targetMatched;
|
||||
}
|
||||
|
||||
function evaluatedTrue(entry: EvaluatedPolicy): boolean {
|
||||
return entry.evaluation?.value === true;
|
||||
}
|
||||
|
||||
function evaluatedUnknownOrError(entry: EvaluatedPolicy): boolean {
|
||||
return (
|
||||
entry.evaluation?.value === PERMISSION_EVAL_UNKNOWN ||
|
||||
entry.evaluation?.value === PERMISSION_EVAL_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
export function combineEvaluatedPolicies(
|
||||
evaluated: readonly EvaluatedPolicy[],
|
||||
defaultFallback: PermissionFallback = PERMISSION_FALLBACK_DENY
|
||||
): PermissionDecision {
|
||||
const applicable = evaluated.filter((entry) => matched(entry) && evaluatedTrue(entry));
|
||||
const deny = applicable
|
||||
.filter((entry) => entry.policy.effect === PERMISSION_EFFECT_DENY)
|
||||
.sort(byPriorityDesc)[0];
|
||||
|
||||
if (deny) {
|
||||
return {
|
||||
effect: PERMISSION_EFFECT_DENY,
|
||||
policy: deny.policy.id,
|
||||
code: deny.policy.code ?? PERMISSION_DECISION_CODE_DENIED,
|
||||
reason: deny.policy.reason ?? permissionDeniedByPolicyReason(deny.policy.id),
|
||||
advice: deny.policy.advice
|
||||
};
|
||||
}
|
||||
|
||||
const denyIndeterminate = evaluated
|
||||
.filter(
|
||||
(entry) =>
|
||||
matched(entry) &&
|
||||
entry.policy.effect === PERMISSION_EFFECT_DENY &&
|
||||
evaluatedUnknownOrError(entry)
|
||||
)
|
||||
.sort(byPriorityDesc)[0];
|
||||
|
||||
if (denyIndeterminate?.evaluation) {
|
||||
return {
|
||||
effect: PERMISSION_EFFECT_INDETERMINATE,
|
||||
policy: denyIndeterminate.policy.id,
|
||||
reason:
|
||||
denyIndeterminate.evaluation.reason ??
|
||||
denyIndeterminate.policy.reason ??
|
||||
PERMISSION_ERROR_MSG_DENY_INDETERMINATE,
|
||||
fallback: PERMISSION_FALLBACK_DENY,
|
||||
errors: denyIndeterminate.evaluation.error ? [denyIndeterminate.evaluation.error] : undefined
|
||||
};
|
||||
}
|
||||
|
||||
const allow = applicable
|
||||
.filter((entry) => entry.policy.effect === PERMISSION_EFFECT_ALLOW)
|
||||
.sort(byPriorityDesc)[0];
|
||||
|
||||
if (allow) {
|
||||
return {
|
||||
effect: PERMISSION_EFFECT_ALLOW,
|
||||
policy: allow.policy.id,
|
||||
reason: allow.policy.reason,
|
||||
obligations: allow.policy.obligations,
|
||||
advice: allow.policy.advice
|
||||
};
|
||||
}
|
||||
|
||||
const errored = evaluated.find(
|
||||
(entry) => matched(entry) && entry.evaluation?.value === PERMISSION_EVAL_ERROR
|
||||
);
|
||||
if (errored?.evaluation) {
|
||||
return {
|
||||
effect: PERMISSION_EFFECT_INDETERMINATE,
|
||||
reason: errored.evaluation.reason ?? PERMISSION_ERROR_MSG_EVALUATION_FAILED_DECISION,
|
||||
fallback: defaultFallback,
|
||||
errors: errored.evaluation.error ? [errored.evaluation.error] : undefined
|
||||
};
|
||||
}
|
||||
|
||||
const unknown = evaluated.find(
|
||||
(entry) => matched(entry) && entry.evaluation?.value === PERMISSION_EVAL_UNKNOWN
|
||||
);
|
||||
if (unknown?.evaluation) {
|
||||
return {
|
||||
effect: PERMISSION_EFFECT_INDETERMINATE,
|
||||
reason: unknown.evaluation.reason ?? PERMISSION_ERROR_MSG_EVALUATION_UNKNOWN_DECISION,
|
||||
fallback: defaultFallback
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
effect: PERMISSION_EFFECT_NOT_APPLICABLE,
|
||||
reason: PERMISSION_ERROR_MSG_NOT_APPLICABLE
|
||||
};
|
||||
}
|
||||
|
||||
export function toTrace(evaluated: readonly EvaluatedPolicy[]): TraceEntry[] {
|
||||
return evaluated.map((entry) => ({
|
||||
policy: entry.policy.id,
|
||||
effect: entry.policy.effect,
|
||||
targetMatched: entry.targetMatched,
|
||||
condition: entry.targetMatched ? entry.policy.condition : undefined,
|
||||
result: entry.evaluation?.value,
|
||||
reason: entry.evaluation?.reason,
|
||||
dependencies: entry.evaluation?.dependencies ?? []
|
||||
}));
|
||||
}
|
||||
@ -0,0 +1,234 @@
|
||||
import {
|
||||
PERMISSION_EFFECT_ALLOW,
|
||||
PERMISSION_EFFECT_DENY,
|
||||
PERMISSION_ERROR_MSG_NO_SQL_ALLOW,
|
||||
PERMISSION_ERROR_MSG_SQL_RESIDUAL,
|
||||
PERMISSION_EXPR_AND,
|
||||
PERMISSION_EXPR_CONST,
|
||||
PERMISSION_EXPR_CONTAINS,
|
||||
PERMISSION_EXPR_EQ,
|
||||
PERMISSION_EXPR_EXISTS,
|
||||
PERMISSION_EXPR_GT,
|
||||
PERMISSION_EXPR_GTE,
|
||||
PERMISSION_EXPR_IN,
|
||||
PERMISSION_EXPR_LT,
|
||||
PERMISSION_EXPR_LTE,
|
||||
PERMISSION_EXPR_NEQ,
|
||||
PERMISSION_EXPR_NOT,
|
||||
PERMISSION_EXPR_OR,
|
||||
PERMISSION_EXPR_REF,
|
||||
PERMISSION_EXPR_REL,
|
||||
PERMISSION_QUERY_STRATEGY_COMPILED,
|
||||
PERMISSION_QUERY_STRATEGY_NOT_COMPILABLE,
|
||||
PERMISSION_QUERY_STRATEGY_PARTIAL,
|
||||
PERMISSION_QUERY_TARGET_SQL,
|
||||
PERMISSION_ROOT_ACTOR,
|
||||
PERMISSION_ROOT_CONTEXT,
|
||||
PERMISSION_ROOT_RESOURCE,
|
||||
PERMISSION_SQL_AND,
|
||||
PERMISSION_SQL_DEFAULT_RESOURCE_ALIAS,
|
||||
PERMISSION_SQL_FALSE,
|
||||
PERMISSION_SQL_OR,
|
||||
PERMISSION_SQL_PARAM_PREFIX,
|
||||
PERMISSION_SQL_TRUE
|
||||
} from '../consts.ts';
|
||||
import { actionMatches } from '../match.ts';
|
||||
import type { ExprIR, PolicyIR, QueryCompiler, QueryPlan, SubjectRef } from '../types.ts';
|
||||
|
||||
export interface SqlCompileResult {
|
||||
readonly sql: string;
|
||||
readonly params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SqlRelationCompilerInput {
|
||||
readonly relation: string;
|
||||
readonly resourceAlias: string;
|
||||
readonly actor: SubjectRef;
|
||||
param(value: unknown): string;
|
||||
}
|
||||
|
||||
export type SqlRelationCompiler = (input: SqlRelationCompilerInput) => string | undefined;
|
||||
|
||||
export interface CreateSqlCompilerOptions {
|
||||
readonly resourceAlias?: string;
|
||||
readonly relation?: SqlRelationCompiler;
|
||||
readonly columnName?: (path: string) => string;
|
||||
}
|
||||
|
||||
function defaultColumnName(path: string): string {
|
||||
return path.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
|
||||
}
|
||||
|
||||
function relevantPolicies(
|
||||
policies: readonly PolicyIR[],
|
||||
action: string,
|
||||
resourceType: string
|
||||
): PolicyIR[] {
|
||||
return policies.filter((policy) => {
|
||||
const resourceMatches = !policy.target.resource || policy.target.resource === resourceType;
|
||||
return resourceMatches && actionMatches(policy.target.action, action);
|
||||
});
|
||||
}
|
||||
|
||||
export function createSqlCompiler(options: CreateSqlCompilerOptions = {}): QueryCompiler {
|
||||
const alias = options.resourceAlias ?? PERMISSION_SQL_DEFAULT_RESOURCE_ALIAS;
|
||||
const columnName = options.columnName ?? defaultColumnName;
|
||||
|
||||
return {
|
||||
target: PERMISSION_QUERY_TARGET_SQL,
|
||||
compile(input): QueryPlan {
|
||||
const policies = relevantPolicies(input.policies, input.action, input.resourceType);
|
||||
const allowPolicies = policies.filter((policy) => policy.effect === PERMISSION_EFFECT_ALLOW);
|
||||
const denyPolicies = policies.filter((policy) => policy.effect === PERMISSION_EFFECT_DENY);
|
||||
const params: Record<string, unknown> = {};
|
||||
let counter = 0;
|
||||
const warnings: string[] = [];
|
||||
const residualPolicies: PolicyIR[] = [];
|
||||
|
||||
const param = (value: unknown): string => {
|
||||
const key = `${PERMISSION_SQL_PARAM_PREFIX}${++counter}`;
|
||||
params[key] = value;
|
||||
return `:${key}`;
|
||||
};
|
||||
|
||||
const compileExpr = (expr: ExprIR): string | undefined => {
|
||||
switch (expr.op) {
|
||||
case PERMISSION_EXPR_CONST:
|
||||
if (typeof expr.value === 'boolean') {
|
||||
return expr.value ? PERMISSION_SQL_TRUE : PERMISSION_SQL_FALSE;
|
||||
}
|
||||
return param(expr.value);
|
||||
|
||||
case PERMISSION_EXPR_REF:
|
||||
if (expr.root === PERMISSION_ROOT_RESOURCE) return `${alias}.${columnName(expr.path)}`;
|
||||
if (expr.root === PERMISSION_ROOT_ACTOR) {
|
||||
const value = expr.path
|
||||
? (input.actor as Record<string, unknown>)[expr.path]
|
||||
: input.actor;
|
||||
return param(value);
|
||||
}
|
||||
if (expr.root === PERMISSION_ROOT_CONTEXT) {
|
||||
const value = expr.path ? input.context?.[expr.path] : input.context;
|
||||
return param(value);
|
||||
}
|
||||
return undefined;
|
||||
|
||||
case PERMISSION_EXPR_EQ:
|
||||
case PERMISSION_EXPR_NEQ:
|
||||
case PERMISSION_EXPR_GT:
|
||||
case PERMISSION_EXPR_GTE:
|
||||
case PERMISSION_EXPR_LT:
|
||||
case PERMISSION_EXPR_LTE: {
|
||||
const left = compileExpr(expr.left);
|
||||
const right = compileExpr(expr.right);
|
||||
if (!left || !right) return undefined;
|
||||
const op =
|
||||
expr.op === PERMISSION_EXPR_EQ
|
||||
? '='
|
||||
: expr.op === PERMISSION_EXPR_NEQ
|
||||
? '<>'
|
||||
: expr.op === PERMISSION_EXPR_GT
|
||||
? '>'
|
||||
: expr.op === PERMISSION_EXPR_GTE
|
||||
? '>='
|
||||
: expr.op === PERMISSION_EXPR_LT
|
||||
? '<'
|
||||
: '<=';
|
||||
return `(${left} ${op} ${right})`;
|
||||
}
|
||||
|
||||
case PERMISSION_EXPR_AND: {
|
||||
const parts = expr.args.map(compileExpr);
|
||||
if (parts.some((part) => !part)) return undefined;
|
||||
return `(${parts.join(PERMISSION_SQL_AND)})`;
|
||||
}
|
||||
|
||||
case PERMISSION_EXPR_OR: {
|
||||
const parts = expr.args.map(compileExpr);
|
||||
if (parts.some((part) => !part)) return undefined;
|
||||
return `(${parts.join(PERMISSION_SQL_OR)})`;
|
||||
}
|
||||
|
||||
case PERMISSION_EXPR_NOT: {
|
||||
const inner = compileExpr(expr.expr);
|
||||
return inner ? `(NOT ${inner})` : undefined;
|
||||
}
|
||||
|
||||
case PERMISSION_EXPR_IN: {
|
||||
const value = compileExpr(expr.value);
|
||||
if (!value) return undefined;
|
||||
if (expr.set.op === PERMISSION_EXPR_CONST && Array.isArray(expr.set.value)) {
|
||||
const list = expr.set.value.map(param).join(', ');
|
||||
return `(${value} IN (${list}))`;
|
||||
}
|
||||
const set = compileExpr(expr.set);
|
||||
return set ? `(${value} IN ${set})` : undefined;
|
||||
}
|
||||
|
||||
case PERMISSION_EXPR_CONTAINS: {
|
||||
const value = compileExpr(expr.value);
|
||||
if (!value) return undefined;
|
||||
if (expr.set.op === PERMISSION_EXPR_CONST && Array.isArray(expr.set.value)) {
|
||||
const list = expr.set.value.map(param).join(', ');
|
||||
return `(${value} IN (${list}))`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
case PERMISSION_EXPR_REL: {
|
||||
const sql = options.relation?.({
|
||||
relation: expr.path,
|
||||
resourceAlias: alias,
|
||||
actor: input.actor,
|
||||
param
|
||||
});
|
||||
return sql ? `(${sql})` : undefined;
|
||||
}
|
||||
|
||||
case PERMISSION_EXPR_EXISTS:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const allowSql: string[] = [];
|
||||
for (const policy of allowPolicies) {
|
||||
const compiled = compileExpr(policy.condition);
|
||||
if (compiled) allowSql.push(compiled);
|
||||
else residualPolicies.push(policy);
|
||||
}
|
||||
|
||||
const denySql: string[] = [];
|
||||
for (const policy of denyPolicies) {
|
||||
const compiled = compileExpr(policy.condition);
|
||||
if (compiled) denySql.push(compiled);
|
||||
else residualPolicies.push(policy);
|
||||
}
|
||||
|
||||
if (allowSql.length === 0 && residualPolicies.length > 0) {
|
||||
return {
|
||||
strategy: PERMISSION_QUERY_STRATEGY_NOT_COMPILABLE,
|
||||
target: PERMISSION_QUERY_TARGET_SQL,
|
||||
residualPolicies,
|
||||
warnings: [PERMISSION_ERROR_MSG_NO_SQL_ALLOW]
|
||||
};
|
||||
}
|
||||
|
||||
let sql =
|
||||
allowSql.length > 0 ? `(${allowSql.join(PERMISSION_SQL_OR)})` : PERMISSION_SQL_FALSE;
|
||||
if (denySql.length > 0) sql = `(${sql}) AND NOT (${denySql.join(PERMISSION_SQL_OR)})`;
|
||||
|
||||
if (residualPolicies.length > 0) warnings.push(PERMISSION_ERROR_MSG_SQL_RESIDUAL);
|
||||
|
||||
return {
|
||||
strategy:
|
||||
residualPolicies.length > 0
|
||||
? PERMISSION_QUERY_STRATEGY_PARTIAL
|
||||
: PERMISSION_QUERY_STRATEGY_COMPILED,
|
||||
target: PERMISSION_QUERY_TARGET_SQL,
|
||||
predicate: { sql, params } satisfies SqlCompileResult,
|
||||
residualPolicies: residualPolicies.length ? residualPolicies : undefined,
|
||||
warnings: warnings.length ? warnings : undefined
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,162 @@
|
||||
export const PERMISSION_EFFECT_ALLOW = 'allow';
|
||||
export const PERMISSION_EFFECT_DENY = 'deny';
|
||||
export const PERMISSION_EFFECT_INDETERMINATE = 'indeterminate';
|
||||
export const PERMISSION_EFFECT_NOT_APPLICABLE = 'not_applicable';
|
||||
|
||||
export const PERMISSION_EFFECTS = [
|
||||
PERMISSION_EFFECT_ALLOW,
|
||||
PERMISSION_EFFECT_DENY,
|
||||
PERMISSION_EFFECT_INDETERMINATE,
|
||||
PERMISSION_EFFECT_NOT_APPLICABLE
|
||||
] as const;
|
||||
|
||||
export const PERMISSION_FALLBACK_DENY = 'deny';
|
||||
export const PERMISSION_FALLBACK_ALLOW = 'allow';
|
||||
|
||||
export const PERMISSION_FALLBACKS = [PERMISSION_FALLBACK_DENY, PERMISSION_FALLBACK_ALLOW] as const;
|
||||
|
||||
export const PERMISSION_EVAL_TRUE = true;
|
||||
export const PERMISSION_EVAL_FALSE = false;
|
||||
export const PERMISSION_EVAL_UNKNOWN = 'unknown';
|
||||
export const PERMISSION_EVAL_ERROR = 'error';
|
||||
|
||||
export const PERMISSION_DEP_ACTOR = 'actor';
|
||||
export const PERMISSION_DEP_RESOURCE = 'resource';
|
||||
export const PERMISSION_DEP_CONTEXT = 'context';
|
||||
export const PERMISSION_DEP_RELATION = 'relation';
|
||||
|
||||
export const PERMISSION_DEPENDENCY_KINDS = [
|
||||
PERMISSION_DEP_ACTOR,
|
||||
PERMISSION_DEP_RESOURCE,
|
||||
PERMISSION_DEP_CONTEXT,
|
||||
PERMISSION_DEP_RELATION
|
||||
] as const;
|
||||
|
||||
export const PERMISSION_ROOT_ACTOR = 'actor';
|
||||
export const PERMISSION_ROOT_RESOURCE = 'resource';
|
||||
export const PERMISSION_ROOT_CONTEXT = 'context';
|
||||
|
||||
export const PERMISSION_REF_ROOTS = [
|
||||
PERMISSION_ROOT_ACTOR,
|
||||
PERMISSION_ROOT_RESOURCE,
|
||||
PERMISSION_ROOT_CONTEXT
|
||||
] as const;
|
||||
|
||||
export const PERMISSION_EXPR_CONST = 'const';
|
||||
export const PERMISSION_EXPR_REF = 'ref';
|
||||
export const PERMISSION_EXPR_EQ = 'eq';
|
||||
export const PERMISSION_EXPR_NEQ = 'neq';
|
||||
export const PERMISSION_EXPR_GT = 'gt';
|
||||
export const PERMISSION_EXPR_GTE = 'gte';
|
||||
export const PERMISSION_EXPR_LT = 'lt';
|
||||
export const PERMISSION_EXPR_LTE = 'lte';
|
||||
export const PERMISSION_EXPR_IN = 'in';
|
||||
export const PERMISSION_EXPR_CONTAINS = 'contains';
|
||||
export const PERMISSION_EXPR_AND = 'and';
|
||||
export const PERMISSION_EXPR_OR = 'or';
|
||||
export const PERMISSION_EXPR_NOT = 'not';
|
||||
export const PERMISSION_EXPR_REL = 'rel';
|
||||
export const PERMISSION_EXPR_EXISTS = 'exists';
|
||||
|
||||
export const PERMISSION_ACTION_WILDCARD_SUFFIX = '.*';
|
||||
export const PERMISSION_ACTION_SEPARATOR = '.';
|
||||
export const PERMISSION_PATH_SEPARATOR = '.';
|
||||
export const PERMISSION_RESOURCE_KEY_SEPARATOR = ':';
|
||||
export const PERMISSION_RESOURCE_KEY_NONE = 'none';
|
||||
export const PERMISSION_RESOURCE_KEY_UNKNOWN = 'unknown';
|
||||
export const PERMISSION_DEFAULT_SUBJECT_TYPE = 'user';
|
||||
export const PERMISSION_DEFAULT_RESOURCE_TYPE = 'resource';
|
||||
|
||||
export const PERMISSION_POLICY_DEFAULT_PRIORITY = 0;
|
||||
export const PERMISSION_POLICY_AUTO_SEPARATOR = '.';
|
||||
|
||||
export const PERMISSION_DECISION_CODE_DENIED = 'permission_denied';
|
||||
export const PERMISSION_DECISION_CODE_SNAPSHOT_DENIED = 'snapshot_denied';
|
||||
|
||||
export const PERMISSION_OBLIGATION_MASK = 'mask';
|
||||
export const PERMISSION_OBLIGATION_REDACT = 'redact';
|
||||
export const PERMISSION_OBLIGATION_REQUIRE_MFA = 'require_mfa';
|
||||
export const PERMISSION_OBLIGATION_AUDIT = 'audit';
|
||||
|
||||
export const PERMISSION_MASK_MODE_FULL = 'full';
|
||||
export const PERMISSION_MASK_MODE_PARTIAL = 'partial';
|
||||
|
||||
export const PERMISSION_SEVERITY_LOW = 'low';
|
||||
export const PERMISSION_SEVERITY_MEDIUM = 'medium';
|
||||
export const PERMISSION_SEVERITY_HIGH = 'high';
|
||||
|
||||
export const PERMISSION_QUERY_STRATEGY_COMPILED = 'compiled';
|
||||
export const PERMISSION_QUERY_STRATEGY_PARTIAL = 'partial';
|
||||
export const PERMISSION_QUERY_STRATEGY_NOT_COMPILABLE = 'not_compilable';
|
||||
|
||||
export const PERMISSION_QUERY_TARGET_MEMORY = 'memory';
|
||||
export const PERMISSION_QUERY_TARGET_SQL = 'sql';
|
||||
|
||||
export const PERMISSION_REVERSE_SOURCE_RELATION = 'relation';
|
||||
export const PERMISSION_REVERSE_SOURCE_RELATION_EXPANSION = 'relationExpansion';
|
||||
export const PERMISSION_REVERSE_SOURCE_POLICY = 'policy';
|
||||
|
||||
export const PERMISSION_REVERSE_CONSTRAINT_CONTEXT = 'context';
|
||||
export const PERMISSION_REVERSE_CONSTRAINT_ATTRIBUTE = 'attribute';
|
||||
export const PERMISSION_REVERSE_CONSTRAINT_UNKNOWN = 'unknown';
|
||||
|
||||
export const PERMISSION_SQL_DEFAULT_RESOURCE_ALIAS = 'resource';
|
||||
export const PERMISSION_SQL_PARAM_PREFIX = 'p';
|
||||
export const PERMISSION_SQL_TRUE = 'TRUE';
|
||||
export const PERMISSION_SQL_FALSE = 'FALSE';
|
||||
export const PERMISSION_SQL_AND = ' AND ';
|
||||
export const PERMISSION_SQL_OR = ' OR ';
|
||||
|
||||
export const PERMISSION_ERROR_NAME_DENIED = 'PermissionDeniedError';
|
||||
export const PERMISSION_ERROR_NAME_SCHEMA = 'PermissionSchemaError';
|
||||
export const PERMISSION_ERROR_NAME_RUNTIME = 'PermissionRuntimeError';
|
||||
|
||||
export const PERMISSION_ERROR_MSG_DENIED_PREFIX = 'Permission denied: ';
|
||||
export const PERMISSION_ERROR_MSG_SCHEMA_REQUIRES_RESOURCE =
|
||||
'permission schema requires at least one resource';
|
||||
export const PERMISSION_ERROR_MSG_RESOURCE_REQUIRES_ACTIONS_PREFIX =
|
||||
'resource requires actions[]: ';
|
||||
export const PERMISSION_ERROR_MSG_RELATION_UNKNOWN_FROM_PREFIX =
|
||||
'relation points from unknown resource: ';
|
||||
export const PERMISSION_ERROR_MSG_RELATION_UNKNOWN_TO_PREFIX =
|
||||
'relation points to unknown actor/resource: ';
|
||||
export const PERMISSION_ERROR_MSG_EVALUATION_FAILED = 'Expression evaluation failed';
|
||||
export const PERMISSION_ERROR_MSG_COMPARE_PREFIX = 'Cannot compare values using ';
|
||||
export const PERMISSION_ERROR_MSG_IN_REQUIRES_ARRAY = 'Right side of in() is not an array';
|
||||
export const PERMISSION_ERROR_MSG_CONTAINS_REQUIRES_ARRAY = 'contains() target is not an array';
|
||||
export const PERMISSION_ERROR_MSG_RELATION_REQUIRES_RESOURCE_PREFIX =
|
||||
'Relation requires a resource: ';
|
||||
export const PERMISSION_ERROR_MSG_RELATION_REQUIRES_SUBJECT_PREFIX =
|
||||
'Relation requires a subject: ';
|
||||
export const PERMISSION_ERROR_MSG_RELATION_PROVIDER_MISSING_PREFIX =
|
||||
'No relation provider configured for ';
|
||||
export const PERMISSION_ERROR_MSG_RELATION_UNKNOWN_PREFIX = 'Relation is unknown: ';
|
||||
export const PERMISSION_ERROR_MSG_EXISTS_REQUIRES_PROVIDER =
|
||||
'exists() requires a query-capable provider';
|
||||
export const PERMISSION_ERROR_MSG_EVALUATION_FAILED_DECISION =
|
||||
'Authorization policy evaluation failed';
|
||||
export const PERMISSION_ERROR_MSG_EVALUATION_UNKNOWN_DECISION =
|
||||
'Authorization policy evaluation is unknown';
|
||||
export const PERMISSION_ERROR_MSG_DENY_INDETERMINATE = 'Deny policy could not be evaluated safely';
|
||||
export const PERMISSION_ERROR_MSG_NOT_APPLICABLE =
|
||||
'No matching policy allowed or denied the request';
|
||||
export const PERMISSION_ERROR_MSG_FILTER_REQUIRES_ACTOR =
|
||||
'filter().for(actor) is required before this operation';
|
||||
export const PERMISSION_ERROR_MSG_NO_COMPILER_PREFIX = 'No compiler configured for target: ';
|
||||
export const PERMISSION_ERROR_MSG_NO_SQL_ALLOW = 'No allow policy could be compiled to SQL.';
|
||||
export const PERMISSION_ERROR_MSG_SQL_RESIDUAL =
|
||||
'Some policies were not compilable and require residual checks.';
|
||||
export const PERMISSION_ERROR_MSG_REVERSE_NO_RELATION =
|
||||
'No invertible relation was found. The policy may depend only on attributes or custom predicates.';
|
||||
export const PERMISSION_ERROR_MSG_MFA_REQUIRED = 'MFA required';
|
||||
export const PERMISSION_ERROR_MSG_POLICY_UNKNOWN_RESOURCE_PREFIX =
|
||||
'policy targets unknown resource: ';
|
||||
export const PERMISSION_ERROR_MSG_POLICY_UNKNOWN_ACTION_PREFIX = 'policy targets unknown action: ';
|
||||
|
||||
export const permissionAutoPolicyId = (effect: string, action: string, index: number): string =>
|
||||
`${effect}${PERMISSION_POLICY_AUTO_SEPARATOR}${action}${PERMISSION_POLICY_AUTO_SEPARATOR}${index}`;
|
||||
|
||||
export const permissionDependencyKey = (kind: string, key: string): string => `${kind}:${key}`;
|
||||
|
||||
export const permissionDeniedByPolicyReason = (policy: string): string =>
|
||||
`Denied by policy ${policy}`;
|
||||
@ -0,0 +1,48 @@
|
||||
import {
|
||||
PERMISSION_EFFECT_DENY,
|
||||
PERMISSION_ERROR_MSG_DENIED_PREFIX,
|
||||
PERMISSION_ERROR_NAME_DENIED,
|
||||
PERMISSION_ERROR_NAME_RUNTIME,
|
||||
PERMISSION_ERROR_NAME_SCHEMA
|
||||
} from './consts.ts';
|
||||
import type { PermissionDecision } from './types.ts';
|
||||
|
||||
export class PermissionDeniedError extends Error {
|
||||
readonly decision: PermissionDecision;
|
||||
|
||||
constructor(decision: PermissionDecision) {
|
||||
super(
|
||||
decision.effect === PERMISSION_EFFECT_DENY
|
||||
? decision.reason
|
||||
: `${PERMISSION_ERROR_MSG_DENIED_PREFIX}${decision.effect}`
|
||||
);
|
||||
this.name = PERMISSION_ERROR_NAME_DENIED;
|
||||
this.decision = decision;
|
||||
}
|
||||
}
|
||||
|
||||
export class PermissionSchemaError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = PERMISSION_ERROR_NAME_SCHEMA;
|
||||
}
|
||||
}
|
||||
|
||||
export class PermissionRuntimeError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = PERMISSION_ERROR_NAME_RUNTIME;
|
||||
}
|
||||
}
|
||||
|
||||
export function isPermissionDeniedError(error: unknown): error is PermissionDeniedError {
|
||||
return error instanceof PermissionDeniedError;
|
||||
}
|
||||
|
||||
export function isPermissionSchemaError(error: unknown): error is PermissionSchemaError {
|
||||
return error instanceof PermissionSchemaError;
|
||||
}
|
||||
|
||||
export function isPermissionRuntimeError(error: unknown): error is PermissionRuntimeError {
|
||||
return error instanceof PermissionRuntimeError;
|
||||
}
|
||||
@ -0,0 +1,333 @@
|
||||
import {
|
||||
PERMISSION_DEP_RELATION,
|
||||
PERMISSION_ERROR_MSG_COMPARE_PREFIX,
|
||||
PERMISSION_ERROR_MSG_CONTAINS_REQUIRES_ARRAY,
|
||||
PERMISSION_ERROR_MSG_EVALUATION_FAILED,
|
||||
PERMISSION_ERROR_MSG_EXISTS_REQUIRES_PROVIDER,
|
||||
PERMISSION_ERROR_MSG_IN_REQUIRES_ARRAY,
|
||||
PERMISSION_ERROR_MSG_RELATION_PROVIDER_MISSING_PREFIX,
|
||||
PERMISSION_ERROR_MSG_RELATION_REQUIRES_RESOURCE_PREFIX,
|
||||
PERMISSION_ERROR_MSG_RELATION_REQUIRES_SUBJECT_PREFIX,
|
||||
PERMISSION_ERROR_MSG_RELATION_UNKNOWN_PREFIX,
|
||||
PERMISSION_EVAL_ERROR,
|
||||
PERMISSION_EVAL_UNKNOWN,
|
||||
PERMISSION_EXPR_AND,
|
||||
PERMISSION_EXPR_CONST,
|
||||
PERMISSION_EXPR_CONTAINS,
|
||||
PERMISSION_EXPR_EQ,
|
||||
PERMISSION_EXPR_EXISTS,
|
||||
PERMISSION_EXPR_GT,
|
||||
PERMISSION_EXPR_GTE,
|
||||
PERMISSION_EXPR_IN,
|
||||
PERMISSION_EXPR_LT,
|
||||
PERMISSION_EXPR_LTE,
|
||||
PERMISSION_EXPR_NEQ,
|
||||
PERMISSION_EXPR_NOT,
|
||||
PERMISSION_EXPR_OR,
|
||||
PERMISSION_EXPR_REF,
|
||||
PERMISSION_EXPR_REL,
|
||||
PERMISSION_PATH_SEPARATOR,
|
||||
PERMISSION_ROOT_ACTOR,
|
||||
PERMISSION_ROOT_RESOURCE,
|
||||
permissionDependencyKey
|
||||
} from './consts.ts';
|
||||
import { getPath } from './path.ts';
|
||||
import type {
|
||||
DependencyKey,
|
||||
EvalResult,
|
||||
EvalValue,
|
||||
ExprIR,
|
||||
PermissionProviders,
|
||||
PermissionRequestContext,
|
||||
ResourceRef,
|
||||
SubjectRef
|
||||
} from './types.ts';
|
||||
|
||||
function ok(
|
||||
value: EvalValue,
|
||||
dependencies: readonly DependencyKey[] = [],
|
||||
reason?: string,
|
||||
error?: unknown
|
||||
): EvalResult {
|
||||
const result: EvalResult = { value, dependencies };
|
||||
if (reason !== undefined) (result as { reason?: string }).reason = reason;
|
||||
if (error !== undefined) (result as { error?: unknown }).error = error;
|
||||
return result;
|
||||
}
|
||||
|
||||
function dep(kind: string, key: string): DependencyKey {
|
||||
return permissionDependencyKey(kind, key) as DependencyKey;
|
||||
}
|
||||
|
||||
function dedupeDeps(deps: readonly DependencyKey[]): DependencyKey[] {
|
||||
return [...new Set(deps)];
|
||||
}
|
||||
|
||||
function compare(
|
||||
op: ExprIR['op'],
|
||||
left: unknown,
|
||||
right: unknown
|
||||
): boolean | typeof PERMISSION_EVAL_UNKNOWN {
|
||||
switch (op) {
|
||||
case PERMISSION_EXPR_EQ:
|
||||
return left === right;
|
||||
case PERMISSION_EXPR_NEQ:
|
||||
return left !== right;
|
||||
case PERMISSION_EXPR_GT:
|
||||
return typeof left === 'number' && typeof right === 'number'
|
||||
? left > right
|
||||
: PERMISSION_EVAL_UNKNOWN;
|
||||
case PERMISSION_EXPR_GTE:
|
||||
return typeof left === 'number' && typeof right === 'number'
|
||||
? left >= right
|
||||
: PERMISSION_EVAL_UNKNOWN;
|
||||
case PERMISSION_EXPR_LT:
|
||||
return typeof left === 'number' && typeof right === 'number'
|
||||
? left < right
|
||||
: PERMISSION_EVAL_UNKNOWN;
|
||||
case PERMISSION_EXPR_LTE:
|
||||
return typeof left === 'number' && typeof right === 'number'
|
||||
? left <= right
|
||||
: PERMISSION_EVAL_UNKNOWN;
|
||||
default:
|
||||
return PERMISSION_EVAL_UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
export class DefaultPermissionEvaluator {
|
||||
constructor(private readonly providers: PermissionProviders = {}) {}
|
||||
|
||||
async evaluate(expr: ExprIR, context: PermissionRequestContext): Promise<EvalResult> {
|
||||
try {
|
||||
return await this.evaluateInternal(expr, context);
|
||||
} catch (error) {
|
||||
return ok(PERMISSION_EVAL_ERROR, [], PERMISSION_ERROR_MSG_EVALUATION_FAILED, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async rawValue(
|
||||
expr: ExprIR,
|
||||
context: PermissionRequestContext
|
||||
): Promise<{ value: unknown; dependencies: readonly DependencyKey[]; unknown?: string }> {
|
||||
if (expr.op === PERMISSION_EXPR_CONST) return { value: expr.value, dependencies: [] };
|
||||
|
||||
if (expr.op === PERMISSION_EXPR_REF) {
|
||||
const key = expr.path ? `${expr.root}${PERMISSION_PATH_SEPARATOR}${expr.path}` : expr.root;
|
||||
const dependency = dep(expr.root, key);
|
||||
if (this.providers.attributes) {
|
||||
const provided = await this.providers.attributes.getAttribute({
|
||||
root: expr.root,
|
||||
path: expr.path,
|
||||
context
|
||||
});
|
||||
return { value: provided, dependencies: [dependency] };
|
||||
}
|
||||
|
||||
const root =
|
||||
expr.root === PERMISSION_ROOT_ACTOR
|
||||
? context.actor
|
||||
: expr.root === PERMISSION_ROOT_RESOURCE
|
||||
? context.resource
|
||||
: context.context;
|
||||
return { value: getPath(root, expr.path), dependencies: [dependency] };
|
||||
}
|
||||
|
||||
if (expr.op === PERMISSION_EXPR_REL) {
|
||||
const evaluated = await this.evaluateInternal(expr, context);
|
||||
const out: { value: unknown; dependencies: readonly DependencyKey[]; unknown?: string } = {
|
||||
value: evaluated.value === true,
|
||||
dependencies: evaluated.dependencies
|
||||
};
|
||||
if (evaluated.value === PERMISSION_EVAL_UNKNOWN && evaluated.reason !== undefined) {
|
||||
out.unknown = evaluated.reason;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const evaluated = await this.evaluateInternal(expr, context);
|
||||
if (evaluated.value === PERMISSION_EVAL_UNKNOWN || evaluated.value === PERMISSION_EVAL_ERROR) {
|
||||
return {
|
||||
value: undefined,
|
||||
dependencies: evaluated.dependencies,
|
||||
unknown: evaluated.reason ?? String(evaluated.value)
|
||||
};
|
||||
}
|
||||
return { value: evaluated.value, dependencies: evaluated.dependencies };
|
||||
}
|
||||
|
||||
private async evaluateInternal(
|
||||
expr: ExprIR,
|
||||
context: PermissionRequestContext
|
||||
): Promise<EvalResult> {
|
||||
switch (expr.op) {
|
||||
case PERMISSION_EXPR_CONST:
|
||||
return ok(Boolean(expr.value));
|
||||
|
||||
case PERMISSION_EXPR_REF: {
|
||||
const { value, dependencies } = await this.rawValue(expr, context);
|
||||
return ok(Boolean(value), dependencies);
|
||||
}
|
||||
|
||||
case PERMISSION_EXPR_EQ:
|
||||
case PERMISSION_EXPR_NEQ:
|
||||
case PERMISSION_EXPR_GT:
|
||||
case PERMISSION_EXPR_GTE:
|
||||
case PERMISSION_EXPR_LT:
|
||||
case PERMISSION_EXPR_LTE: {
|
||||
const left = await this.rawValue(expr.left, context);
|
||||
const right = await this.rawValue(expr.right, context);
|
||||
const dependencies = dedupeDeps([...left.dependencies, ...right.dependencies]);
|
||||
if (left.unknown || right.unknown) {
|
||||
return ok(PERMISSION_EVAL_UNKNOWN, dependencies, left.unknown ?? right.unknown);
|
||||
}
|
||||
const value = compare(expr.op, left.value, right.value);
|
||||
return value === PERMISSION_EVAL_UNKNOWN
|
||||
? ok(
|
||||
PERMISSION_EVAL_UNKNOWN,
|
||||
dependencies,
|
||||
`${PERMISSION_ERROR_MSG_COMPARE_PREFIX}${expr.op}`
|
||||
)
|
||||
: ok(value, dependencies);
|
||||
}
|
||||
|
||||
case PERMISSION_EXPR_IN: {
|
||||
const value = await this.rawValue(expr.value, context);
|
||||
const set = await this.rawValue(expr.set, context);
|
||||
const dependencies = dedupeDeps([...value.dependencies, ...set.dependencies]);
|
||||
if (value.unknown || set.unknown) {
|
||||
return ok(PERMISSION_EVAL_UNKNOWN, dependencies, value.unknown ?? set.unknown);
|
||||
}
|
||||
if (!Array.isArray(set.value)) {
|
||||
return ok(PERMISSION_EVAL_UNKNOWN, dependencies, PERMISSION_ERROR_MSG_IN_REQUIRES_ARRAY);
|
||||
}
|
||||
return ok(set.value.includes(value.value), dependencies);
|
||||
}
|
||||
|
||||
case PERMISSION_EXPR_CONTAINS: {
|
||||
const set = await this.rawValue(expr.set, context);
|
||||
const value = await this.rawValue(expr.value, context);
|
||||
const dependencies = dedupeDeps([...value.dependencies, ...set.dependencies]);
|
||||
if (value.unknown || set.unknown) {
|
||||
return ok(PERMISSION_EVAL_UNKNOWN, dependencies, value.unknown ?? set.unknown);
|
||||
}
|
||||
if (!Array.isArray(set.value)) {
|
||||
return ok(
|
||||
PERMISSION_EVAL_UNKNOWN,
|
||||
dependencies,
|
||||
PERMISSION_ERROR_MSG_CONTAINS_REQUIRES_ARRAY
|
||||
);
|
||||
}
|
||||
return ok(set.value.includes(value.value), dependencies);
|
||||
}
|
||||
|
||||
case PERMISSION_EXPR_AND: {
|
||||
const dependencies: DependencyKey[] = [];
|
||||
let unknownReason: string | undefined;
|
||||
for (const arg of expr.args) {
|
||||
const result = await this.evaluateInternal(arg, context);
|
||||
dependencies.push(...result.dependencies);
|
||||
if (result.value === false) return ok(false, dedupeDeps(dependencies));
|
||||
if (result.value === PERMISSION_EVAL_ERROR) {
|
||||
return ok(PERMISSION_EVAL_ERROR, dedupeDeps(dependencies), result.reason, result.error);
|
||||
}
|
||||
if (result.value === PERMISSION_EVAL_UNKNOWN) unknownReason ??= result.reason;
|
||||
}
|
||||
return unknownReason
|
||||
? ok(PERMISSION_EVAL_UNKNOWN, dedupeDeps(dependencies), unknownReason)
|
||||
: ok(true, dedupeDeps(dependencies));
|
||||
}
|
||||
|
||||
case PERMISSION_EXPR_OR: {
|
||||
const dependencies: DependencyKey[] = [];
|
||||
let unknownReason: string | undefined;
|
||||
for (const arg of expr.args) {
|
||||
const result = await this.evaluateInternal(arg, context);
|
||||
dependencies.push(...result.dependencies);
|
||||
if (result.value === true) return ok(true, dedupeDeps(dependencies));
|
||||
if (result.value === PERMISSION_EVAL_ERROR) {
|
||||
return ok(PERMISSION_EVAL_ERROR, dedupeDeps(dependencies), result.reason, result.error);
|
||||
}
|
||||
if (result.value === PERMISSION_EVAL_UNKNOWN) unknownReason ??= result.reason;
|
||||
}
|
||||
return unknownReason
|
||||
? ok(PERMISSION_EVAL_UNKNOWN, dedupeDeps(dependencies), unknownReason)
|
||||
: ok(false, dedupeDeps(dependencies));
|
||||
}
|
||||
|
||||
case PERMISSION_EXPR_NOT: {
|
||||
const result = await this.evaluateInternal(expr.expr, context);
|
||||
if (result.value === true) return ok(false, result.dependencies);
|
||||
if (result.value === false) return ok(true, result.dependencies);
|
||||
return result;
|
||||
}
|
||||
|
||||
case PERMISSION_EXPR_REL: {
|
||||
const relationKey = dep(PERMISSION_DEP_RELATION, expr.path);
|
||||
const resourceValue = expr.resource
|
||||
? await this.rawValue(expr.resource, context)
|
||||
: { value: context.resource, dependencies: [] };
|
||||
const subjectValue = await this.rawValue(expr.subject, context);
|
||||
const dependencies = dedupeDeps([
|
||||
relationKey,
|
||||
...resourceValue.dependencies,
|
||||
...subjectValue.dependencies
|
||||
]);
|
||||
|
||||
if (resourceValue.unknown || subjectValue.unknown) {
|
||||
return ok(
|
||||
PERMISSION_EVAL_UNKNOWN,
|
||||
dependencies,
|
||||
resourceValue.unknown ?? subjectValue.unknown
|
||||
);
|
||||
}
|
||||
|
||||
const resource = resourceValue.value as ResourceRef | undefined;
|
||||
const subject = subjectValue.value as SubjectRef | undefined;
|
||||
|
||||
if (!resource || typeof resource !== 'object' || !resource.type) {
|
||||
return ok(
|
||||
PERMISSION_EVAL_UNKNOWN,
|
||||
dependencies,
|
||||
`${PERMISSION_ERROR_MSG_RELATION_REQUIRES_RESOURCE_PREFIX}${expr.path}`
|
||||
);
|
||||
}
|
||||
if (!subject || typeof subject !== 'object' || !subject.type || !subject.id) {
|
||||
return ok(
|
||||
PERMISSION_EVAL_UNKNOWN,
|
||||
dependencies,
|
||||
`${PERMISSION_ERROR_MSG_RELATION_REQUIRES_SUBJECT_PREFIX}${expr.path}`
|
||||
);
|
||||
}
|
||||
if (!this.providers.relations) {
|
||||
return ok(
|
||||
PERMISSION_EVAL_UNKNOWN,
|
||||
dependencies,
|
||||
`${PERMISSION_ERROR_MSG_RELATION_PROVIDER_MISSING_PREFIX}${expr.path}`
|
||||
);
|
||||
}
|
||||
|
||||
const relationResult = await this.providers.relations.hasRelation({
|
||||
relation: expr.path,
|
||||
resource,
|
||||
subject,
|
||||
context
|
||||
});
|
||||
if (relationResult === PERMISSION_EVAL_UNKNOWN) {
|
||||
return ok(
|
||||
PERMISSION_EVAL_UNKNOWN,
|
||||
dependencies,
|
||||
`${PERMISSION_ERROR_MSG_RELATION_UNKNOWN_PREFIX}${expr.path}`
|
||||
);
|
||||
}
|
||||
return ok(relationResult, dependencies);
|
||||
}
|
||||
|
||||
case PERMISSION_EXPR_EXISTS:
|
||||
return ok(
|
||||
PERMISSION_EVAL_UNKNOWN,
|
||||
[dep(PERMISSION_DEP_RELATION, expr.relation)],
|
||||
PERMISSION_ERROR_MSG_EXISTS_REQUIRES_PROVIDER
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
export * from './consts.ts';
|
||||
export * from './types.ts';
|
||||
export * from './errors.ts';
|
||||
export * from './path.ts';
|
||||
export * from './schema.ts';
|
||||
export * from './builder.ts';
|
||||
export * from './match.ts';
|
||||
export * from './evaluator.ts';
|
||||
export * from './combiner.ts';
|
||||
export * from './reverse.ts';
|
||||
export * from './runtime.ts';
|
||||
export * from './compilers/sql.ts';
|
||||
@ -0,0 +1,28 @@
|
||||
import { PERMISSION_ACTION_WILDCARD_SUFFIX, PERMISSION_PATH_SEPARATOR } from './consts.ts';
|
||||
import { actionResource } from './schema.ts';
|
||||
import type { PermissionRequestContext, PolicyIR } from './types.ts';
|
||||
|
||||
export function actionMatches(pattern: string, action: string): boolean {
|
||||
if (pattern === action) return true;
|
||||
if (pattern.endsWith(PERMISSION_ACTION_WILDCARD_SUFFIX)) {
|
||||
const prefix = pattern.slice(0, -1);
|
||||
return action.startsWith(prefix);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function targetMatches(policy: PolicyIR, context: PermissionRequestContext): boolean {
|
||||
if (!actionMatches(policy.target.action, context.action)) return false;
|
||||
const policyResource = policy.target.resource ?? actionResource(policy.target.action);
|
||||
const requestResource = context.resource?.type ?? actionResource(context.action);
|
||||
if (!policyResource || !requestResource) return true;
|
||||
return policyResource === requestResource;
|
||||
}
|
||||
|
||||
export function actionsForResource(resourceType: string, actions: readonly string[]): string[] {
|
||||
return actions.map((action) =>
|
||||
action.includes(PERMISSION_PATH_SEPARATOR)
|
||||
? action
|
||||
: `${resourceType}${PERMISSION_PATH_SEPARATOR}${action}`
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
import type { Dict } from './types.ts';
|
||||
import {
|
||||
PERMISSION_PATH_SEPARATOR,
|
||||
PERMISSION_ROOT_ACTOR,
|
||||
PERMISSION_ROOT_CONTEXT,
|
||||
PERMISSION_ROOT_RESOURCE
|
||||
} from './consts.ts';
|
||||
|
||||
export function getPath(input: unknown, path: string): unknown {
|
||||
if (path === '' || path === '.') return input;
|
||||
const parts = path.split(PERMISSION_PATH_SEPARATOR).filter(Boolean);
|
||||
let current: unknown = input;
|
||||
for (const part of parts) {
|
||||
if (current == null || typeof current !== 'object') return undefined;
|
||||
current = (current as Dict)[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function setPath(input: Record<string, unknown>, path: string, value: unknown): void {
|
||||
const parts = path.split(PERMISSION_PATH_SEPARATOR).filter(Boolean);
|
||||
if (parts.length === 0) return;
|
||||
let current: Record<string, unknown> = input;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
const next = current[part];
|
||||
if (!next || typeof next !== 'object') current[part] = {};
|
||||
current = current[part] as Record<string, unknown>;
|
||||
}
|
||||
current[parts[parts.length - 1]!] = value;
|
||||
}
|
||||
|
||||
export function stripResourcePrefix(path: string): string {
|
||||
const parts = path.split(PERMISSION_PATH_SEPARATOR).filter(Boolean);
|
||||
if (parts.length <= 1) return path;
|
||||
if (
|
||||
parts[0] === PERMISSION_ROOT_ACTOR ||
|
||||
parts[0] === PERMISSION_ROOT_RESOURCE ||
|
||||
parts[0] === PERMISSION_ROOT_CONTEXT
|
||||
) {
|
||||
return parts.slice(1).join(PERMISSION_PATH_SEPARATOR);
|
||||
}
|
||||
return parts.slice(1).join(PERMISSION_PATH_SEPARATOR);
|
||||
}
|
||||
@ -0,0 +1,111 @@
|
||||
import {
|
||||
PERMISSION_ERROR_MSG_REVERSE_NO_RELATION,
|
||||
PERMISSION_EFFECT_ALLOW,
|
||||
PERMISSION_EXPR_AND,
|
||||
PERMISSION_EXPR_CONTAINS,
|
||||
PERMISSION_EXPR_CONST,
|
||||
PERMISSION_EXPR_EQ,
|
||||
PERMISSION_EXPR_EXISTS,
|
||||
PERMISSION_EXPR_GT,
|
||||
PERMISSION_EXPR_GTE,
|
||||
PERMISSION_EXPR_IN,
|
||||
PERMISSION_EXPR_LT,
|
||||
PERMISSION_EXPR_LTE,
|
||||
PERMISSION_EXPR_NEQ,
|
||||
PERMISSION_EXPR_NOT,
|
||||
PERMISSION_EXPR_OR,
|
||||
PERMISSION_EXPR_REF,
|
||||
PERMISSION_EXPR_REL,
|
||||
PERMISSION_PATH_SEPARATOR,
|
||||
PERMISSION_REVERSE_CONSTRAINT_ATTRIBUTE,
|
||||
PERMISSION_REVERSE_CONSTRAINT_CONTEXT,
|
||||
PERMISSION_REVERSE_SOURCE_RELATION,
|
||||
PERMISSION_REVERSE_SOURCE_RELATION_EXPANSION,
|
||||
PERMISSION_ROOT_CONTEXT
|
||||
} from './consts.ts';
|
||||
import type { ExprIR, PolicyIR, ResourceRef, ReverseQueryResult } from './types.ts';
|
||||
|
||||
function collect(
|
||||
expr: ExprIR,
|
||||
result: ReverseQueryResult,
|
||||
policy: PolicyIR,
|
||||
resource: ResourceRef
|
||||
): void {
|
||||
switch (expr.op) {
|
||||
case PERMISSION_EXPR_REL:
|
||||
result.sources.push({
|
||||
type: expr.path.includes(PERMISSION_PATH_SEPARATOR)
|
||||
? PERMISSION_REVERSE_SOURCE_RELATION_EXPANSION
|
||||
: PERMISSION_REVERSE_SOURCE_RELATION,
|
||||
path: expr.path,
|
||||
resource
|
||||
});
|
||||
return;
|
||||
|
||||
case PERMISSION_EXPR_AND:
|
||||
case PERMISSION_EXPR_OR:
|
||||
for (const arg of expr.args) collect(arg, result, policy, resource);
|
||||
return;
|
||||
|
||||
case PERMISSION_EXPR_NOT:
|
||||
collect(expr.expr, result, policy, resource);
|
||||
return;
|
||||
|
||||
case PERMISSION_EXPR_EQ:
|
||||
case PERMISSION_EXPR_NEQ:
|
||||
case PERMISSION_EXPR_GT:
|
||||
case PERMISSION_EXPR_GTE:
|
||||
case PERMISSION_EXPR_LT:
|
||||
case PERMISSION_EXPR_LTE:
|
||||
if (expr.left.op === PERMISSION_EXPR_REF && expr.right.op === PERMISSION_EXPR_CONST) {
|
||||
if (expr.left.root === PERMISSION_ROOT_CONTEXT) {
|
||||
result.constraints.push({
|
||||
type: PERMISSION_REVERSE_CONSTRAINT_CONTEXT,
|
||||
path: expr.left.path,
|
||||
required: expr.right.value,
|
||||
policy: policy.id
|
||||
});
|
||||
return;
|
||||
}
|
||||
result.constraints.push({
|
||||
type: PERMISSION_REVERSE_CONSTRAINT_ATTRIBUTE,
|
||||
path: `${expr.left.root}${PERMISSION_PATH_SEPARATOR}${expr.left.path}`,
|
||||
required: expr.right.value,
|
||||
policy: policy.id
|
||||
});
|
||||
}
|
||||
return;
|
||||
|
||||
case PERMISSION_EXPR_IN:
|
||||
case PERMISSION_EXPR_CONTAINS:
|
||||
case PERMISSION_EXPR_EXISTS:
|
||||
case PERMISSION_EXPR_CONST:
|
||||
case PERMISSION_EXPR_REF:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function reversePolicies(input: {
|
||||
readonly policies: readonly PolicyIR[];
|
||||
readonly action: string;
|
||||
readonly resource: ResourceRef;
|
||||
readonly subjectType: string;
|
||||
}): ReverseQueryResult {
|
||||
const result: ReverseQueryResult = {
|
||||
action: input.action,
|
||||
resource: input.resource,
|
||||
subjectType: input.subjectType,
|
||||
sources: [],
|
||||
constraints: [],
|
||||
warnings: []
|
||||
};
|
||||
|
||||
for (const policy of input.policies) {
|
||||
if (policy.effect !== PERMISSION_EFFECT_ALLOW) continue;
|
||||
collect(policy.condition, result, policy, input.resource);
|
||||
}
|
||||
|
||||
if (result.sources.length === 0) result.warnings.push(PERMISSION_ERROR_MSG_REVERSE_NO_RELATION);
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -0,0 +1,199 @@
|
||||
import {
|
||||
PERMISSION_ERROR_MSG_FILTER_REQUIRES_ACTOR,
|
||||
PERMISSION_ERROR_MSG_NO_COMPILER_PREFIX,
|
||||
PERMISSION_ACTION_WILDCARD_SUFFIX,
|
||||
PERMISSION_DEFAULT_RESOURCE_TYPE,
|
||||
PERMISSION_DEFAULT_SUBJECT_TYPE,
|
||||
PERMISSION_EFFECT_ALLOW,
|
||||
PERMISSION_FALLBACK_DENY,
|
||||
PERMISSION_QUERY_STRATEGY_COMPILED,
|
||||
PERMISSION_QUERY_STRATEGY_NOT_COMPILABLE,
|
||||
PERMISSION_QUERY_TARGET_MEMORY
|
||||
} from './consts.ts';
|
||||
import { PermissionDeniedError } from './errors.ts';
|
||||
import { DefaultPermissionEvaluator } from './evaluator.ts';
|
||||
import { combineEvaluatedPolicies, toTrace, type EvaluatedPolicy } from './combiner.ts';
|
||||
import { actionsForResource, targetMatches } from './match.ts';
|
||||
import { reversePolicies } from './reverse.ts';
|
||||
import { actionResource } from './schema.ts';
|
||||
import type {
|
||||
DependencyKey,
|
||||
ExplainResult,
|
||||
PermissionCheckInput,
|
||||
PermissionFilterBuilder,
|
||||
PermissionRequestContext,
|
||||
PermissionRuntime,
|
||||
PermissionRuntimeOptions,
|
||||
PermissionWhatResult,
|
||||
QueryPlan,
|
||||
ResourceRef,
|
||||
ReverseQueryResult,
|
||||
SubjectRef
|
||||
} from './types.ts';
|
||||
|
||||
function dedupe<T>(values: readonly T[]): T[] {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function buildContext(input: PermissionCheckInput): PermissionRequestContext {
|
||||
return {
|
||||
actor: input.actor,
|
||||
action: input.action,
|
||||
resource: input.resource,
|
||||
context: input.context,
|
||||
requestId: input.requestId
|
||||
};
|
||||
}
|
||||
|
||||
export function createPermissionRuntime(options: PermissionRuntimeOptions): PermissionRuntime {
|
||||
const evaluator = new DefaultPermissionEvaluator(options.providers);
|
||||
const defaultFallback = options.defaultFallback ?? PERMISSION_FALLBACK_DENY;
|
||||
|
||||
async function evaluatePolicies(context: PermissionRequestContext): Promise<EvaluatedPolicy[]> {
|
||||
const ordered = [...options.policies].sort((a, b) => b.priority - a.priority);
|
||||
const entries: EvaluatedPolicy[] = [];
|
||||
|
||||
for (const policy of ordered) {
|
||||
const matched = targetMatches(policy, context);
|
||||
if (!matched) {
|
||||
entries.push({ policy, targetMatched: false });
|
||||
continue;
|
||||
}
|
||||
const evaluation = await evaluator.evaluate(policy.condition, context);
|
||||
entries.push({ policy, targetMatched: true, evaluation });
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function explain(input: PermissionCheckInput): Promise<ExplainResult> {
|
||||
const context = buildContext(input);
|
||||
const evaluated = await evaluatePolicies(context);
|
||||
const decision = combineEvaluatedPolicies(evaluated, defaultFallback);
|
||||
const trace = toTrace(evaluated);
|
||||
const dependencies = dedupe(trace.flatMap((entry) => entry.dependencies)) as DependencyKey[];
|
||||
|
||||
return {
|
||||
actor: input.actor,
|
||||
action: input.action,
|
||||
resource: input.resource,
|
||||
decision,
|
||||
trace,
|
||||
dependencies
|
||||
};
|
||||
}
|
||||
|
||||
async function check(input: PermissionCheckInput) {
|
||||
return (await explain(input)).decision;
|
||||
}
|
||||
|
||||
async function can(input: PermissionCheckInput): Promise<boolean> {
|
||||
const decision = await check(input);
|
||||
return decision.effect === PERMISSION_EFFECT_ALLOW;
|
||||
}
|
||||
|
||||
async function assertAllowed(input: PermissionCheckInput): Promise<void> {
|
||||
const decision = await check(input);
|
||||
if (decision.effect !== PERMISSION_EFFECT_ALLOW) throw new PermissionDeniedError(decision);
|
||||
}
|
||||
|
||||
async function what(
|
||||
input: Omit<PermissionCheckInput, 'action'> & { readonly actions?: readonly string[] }
|
||||
): Promise<PermissionWhatResult> {
|
||||
const resourceType = input.resource?.type;
|
||||
const actions =
|
||||
input.actions ??
|
||||
(resourceType && options.schema.resources[resourceType]
|
||||
? actionsForResource(resourceType, options.schema.resources[resourceType]!.actions)
|
||||
: dedupe(
|
||||
options.policies
|
||||
.map((policy) => policy.target.action)
|
||||
.filter((action) => !action.endsWith(PERMISSION_ACTION_WILDCARD_SUFFIX))
|
||||
));
|
||||
|
||||
const decisions: PermissionWhatResult['actions'] = {};
|
||||
for (const action of actions) decisions[action] = await check({ ...input, action });
|
||||
return { resource: input.resource, actions: decisions };
|
||||
}
|
||||
|
||||
async function who(
|
||||
input: PermissionCheckInput & { readonly resource: ResourceRef; readonly subjectType?: string }
|
||||
): Promise<ReverseQueryResult> {
|
||||
const context = buildContext(input);
|
||||
const matchingPolicies = options.policies.filter((policy) => targetMatches(policy, context));
|
||||
return reversePolicies({
|
||||
policies: matchingPolicies,
|
||||
action: input.action,
|
||||
resource: input.resource,
|
||||
subjectType: input.subjectType ?? PERMISSION_DEFAULT_SUBJECT_TYPE
|
||||
});
|
||||
}
|
||||
|
||||
function filter(action: string): PermissionFilterBuilder {
|
||||
let actor: SubjectRef | undefined;
|
||||
let resourceType = actionResource(action) ?? PERMISSION_DEFAULT_RESOURCE_TYPE;
|
||||
let filterContext: Record<string, unknown> | undefined;
|
||||
|
||||
const builder: PermissionFilterBuilder = {
|
||||
for(nextActor: SubjectRef) {
|
||||
actor = nextActor;
|
||||
return builder;
|
||||
},
|
||||
|
||||
resource(nextResourceType: string) {
|
||||
resourceType = nextResourceType;
|
||||
return builder;
|
||||
},
|
||||
|
||||
context(nextContext: Record<string, unknown>) {
|
||||
filterContext = nextContext;
|
||||
return builder;
|
||||
},
|
||||
|
||||
async toPlan(target = PERMISSION_QUERY_TARGET_MEMORY): Promise<QueryPlan> {
|
||||
if (!actor) throw new Error(PERMISSION_ERROR_MSG_FILTER_REQUIRES_ACTOR);
|
||||
if (target === PERMISSION_QUERY_TARGET_MEMORY) {
|
||||
return {
|
||||
strategy: PERMISSION_QUERY_STRATEGY_COMPILED,
|
||||
target: PERMISSION_QUERY_TARGET_MEMORY,
|
||||
predicate: PERMISSION_QUERY_TARGET_MEMORY
|
||||
};
|
||||
}
|
||||
const compiler = options.compilers?.find((candidate) => candidate.target === target);
|
||||
if (!compiler) {
|
||||
return {
|
||||
strategy: PERMISSION_QUERY_STRATEGY_NOT_COMPILABLE,
|
||||
target,
|
||||
warnings: [`${PERMISSION_ERROR_MSG_NO_COMPILER_PREFIX}${target}`]
|
||||
};
|
||||
}
|
||||
return compiler.compile({
|
||||
schema: options.schema,
|
||||
policies: options.policies,
|
||||
action,
|
||||
actor,
|
||||
resourceType,
|
||||
context: filterContext
|
||||
});
|
||||
},
|
||||
|
||||
async toPredicate(): Promise<(resource: ResourceRef) => Promise<boolean>> {
|
||||
if (!actor) throw new Error(PERMISSION_ERROR_MSG_FILTER_REQUIRES_ACTOR);
|
||||
return async (resource: ResourceRef) =>
|
||||
can({ actor: actor!, action, resource, context: filterContext });
|
||||
}
|
||||
};
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
return {
|
||||
check,
|
||||
can,
|
||||
assert: assertAllowed,
|
||||
explain,
|
||||
what,
|
||||
who,
|
||||
filter
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
import {
|
||||
PERMISSION_ACTION_SEPARATOR,
|
||||
PERMISSION_ERROR_MSG_RELATION_UNKNOWN_FROM_PREFIX,
|
||||
PERMISSION_ERROR_MSG_RELATION_UNKNOWN_TO_PREFIX,
|
||||
PERMISSION_ERROR_MSG_RESOURCE_REQUIRES_ACTIONS_PREFIX,
|
||||
PERMISSION_ERROR_MSG_SCHEMA_REQUIRES_RESOURCE,
|
||||
PERMISSION_RESOURCE_KEY_NONE,
|
||||
PERMISSION_RESOURCE_KEY_SEPARATOR,
|
||||
PERMISSION_RESOURCE_KEY_UNKNOWN
|
||||
} from './consts.ts';
|
||||
import { PermissionSchemaError } from './errors.ts';
|
||||
import type { Action, PermSchema, ResourceRef } from './types.ts';
|
||||
|
||||
export function definePermSchema<const T extends PermSchema>(schema: T): T {
|
||||
validatePermSchema(schema);
|
||||
return schema;
|
||||
}
|
||||
|
||||
export function validatePermSchema(schema: PermSchema): void {
|
||||
if (!schema.resources || Object.keys(schema.resources).length === 0) {
|
||||
throw new PermissionSchemaError(PERMISSION_ERROR_MSG_SCHEMA_REQUIRES_RESOURCE);
|
||||
}
|
||||
|
||||
for (const [resource, def] of Object.entries(schema.resources)) {
|
||||
if (!Array.isArray(def.actions) || def.actions.length === 0) {
|
||||
throw new PermissionSchemaError(
|
||||
`${PERMISSION_ERROR_MSG_RESOURCE_REQUIRES_ACTIONS_PREFIX}${resource}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [relation, def] of Object.entries(schema.relations ?? {})) {
|
||||
if (!schema.resources[def.from]) {
|
||||
throw new PermissionSchemaError(
|
||||
`${PERMISSION_ERROR_MSG_RELATION_UNKNOWN_FROM_PREFIX}${relation}`
|
||||
);
|
||||
}
|
||||
const toIsResource = Boolean(schema.resources[def.to]);
|
||||
const toIsActor = Boolean(schema.actors?.[def.to]);
|
||||
if (!toIsResource && !toIsActor) {
|
||||
throw new PermissionSchemaError(
|
||||
`${PERMISSION_ERROR_MSG_RELATION_UNKNOWN_TO_PREFIX}${relation}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function actionResource(action: Action): string | undefined {
|
||||
const [resource] = String(action).split(PERMISSION_ACTION_SEPARATOR);
|
||||
return resource || undefined;
|
||||
}
|
||||
|
||||
export function resourceKey(resource?: ResourceRef | string): string {
|
||||
if (!resource) return PERMISSION_RESOURCE_KEY_NONE;
|
||||
if (typeof resource === 'string') return resource;
|
||||
return `${resource.type}${PERMISSION_RESOURCE_KEY_SEPARATOR}${resource.id ?? PERMISSION_RESOURCE_KEY_UNKNOWN}`;
|
||||
}
|
||||
@ -0,0 +1,347 @@
|
||||
import type {
|
||||
PERMISSION_DEPENDENCY_KINDS,
|
||||
PERMISSION_EFFECT_ALLOW,
|
||||
PERMISSION_EFFECT_DENY,
|
||||
PERMISSION_EFFECT_INDETERMINATE,
|
||||
PERMISSION_EFFECT_NOT_APPLICABLE,
|
||||
PERMISSION_FALLBACK_ALLOW,
|
||||
PERMISSION_FALLBACK_DENY,
|
||||
PERMISSION_QUERY_STRATEGY_COMPILED,
|
||||
PERMISSION_QUERY_STRATEGY_NOT_COMPILABLE,
|
||||
PERMISSION_QUERY_STRATEGY_PARTIAL,
|
||||
PERMISSION_REVERSE_CONSTRAINT_ATTRIBUTE,
|
||||
PERMISSION_REVERSE_CONSTRAINT_CONTEXT,
|
||||
PERMISSION_REVERSE_CONSTRAINT_UNKNOWN,
|
||||
PERMISSION_REVERSE_SOURCE_POLICY,
|
||||
PERMISSION_REVERSE_SOURCE_RELATION,
|
||||
PERMISSION_REVERSE_SOURCE_RELATION_EXPANSION,
|
||||
PERMISSION_ROOT_ACTOR,
|
||||
PERMISSION_ROOT_CONTEXT,
|
||||
PERMISSION_ROOT_RESOURCE,
|
||||
PERMISSION_SEVERITY_HIGH,
|
||||
PERMISSION_SEVERITY_LOW,
|
||||
PERMISSION_SEVERITY_MEDIUM
|
||||
} from './consts.ts';
|
||||
|
||||
export type Primitive = string | number | boolean | null;
|
||||
export type JsonValue = Primitive | JsonValue[] | { readonly [key: string]: JsonValue };
|
||||
export type Dict<T = unknown> = Record<string, T>;
|
||||
|
||||
export type ResourceType = string;
|
||||
export type ActorType = string;
|
||||
export type Action = `${string}.${string}` | `${string}.*` | string;
|
||||
|
||||
export interface SubjectRef {
|
||||
readonly type: ActorType;
|
||||
readonly id: string;
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ResourceRef {
|
||||
readonly type: ResourceType;
|
||||
readonly id?: string;
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PermissionRequestContext {
|
||||
readonly actor: SubjectRef;
|
||||
readonly action: string;
|
||||
readonly resource?: ResourceRef;
|
||||
readonly context?: Dict;
|
||||
readonly requestId?: string;
|
||||
}
|
||||
|
||||
export type PermissionEffect =
|
||||
| typeof PERMISSION_EFFECT_ALLOW
|
||||
| typeof PERMISSION_EFFECT_DENY
|
||||
| typeof PERMISSION_EFFECT_INDETERMINATE
|
||||
| typeof PERMISSION_EFFECT_NOT_APPLICABLE;
|
||||
|
||||
export type PermissionFallback = typeof PERMISSION_FALLBACK_DENY | typeof PERMISSION_FALLBACK_ALLOW;
|
||||
|
||||
export interface ObligationIR {
|
||||
readonly type: string;
|
||||
readonly field?: string;
|
||||
readonly mode?: string;
|
||||
readonly event?: string;
|
||||
readonly severity?:
|
||||
| typeof PERMISSION_SEVERITY_LOW
|
||||
| typeof PERMISSION_SEVERITY_MEDIUM
|
||||
| typeof PERMISSION_SEVERITY_HIGH;
|
||||
readonly reason?: string;
|
||||
readonly meta?: Dict;
|
||||
}
|
||||
|
||||
export interface AdviceIR {
|
||||
readonly type: string;
|
||||
readonly message?: string;
|
||||
readonly meta?: Dict;
|
||||
}
|
||||
|
||||
export type PermissionDecision =
|
||||
| {
|
||||
readonly effect: typeof PERMISSION_EFFECT_ALLOW;
|
||||
readonly policy: string;
|
||||
readonly reason?: string;
|
||||
readonly obligations?: readonly ObligationIR[];
|
||||
readonly advice?: readonly AdviceIR[];
|
||||
readonly ttl?: number;
|
||||
}
|
||||
| {
|
||||
readonly effect: typeof PERMISSION_EFFECT_DENY;
|
||||
readonly policy?: string;
|
||||
readonly code: string;
|
||||
readonly reason: string;
|
||||
readonly advice?: readonly AdviceIR[];
|
||||
}
|
||||
| {
|
||||
readonly effect: typeof PERMISSION_EFFECT_INDETERMINATE;
|
||||
readonly reason: string;
|
||||
readonly fallback: PermissionFallback;
|
||||
readonly policy?: string;
|
||||
readonly errors?: readonly unknown[];
|
||||
}
|
||||
| {
|
||||
readonly effect: typeof PERMISSION_EFFECT_NOT_APPLICABLE;
|
||||
readonly reason?: string;
|
||||
};
|
||||
|
||||
export type PermissionRefRoot =
|
||||
| typeof PERMISSION_ROOT_ACTOR
|
||||
| typeof PERMISSION_ROOT_RESOURCE
|
||||
| typeof PERMISSION_ROOT_CONTEXT;
|
||||
|
||||
export type ExprIR =
|
||||
| { readonly op: 'const'; readonly value: unknown }
|
||||
| { readonly op: 'ref'; readonly root: PermissionRefRoot; readonly path: string }
|
||||
| {
|
||||
readonly op: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte';
|
||||
readonly left: ExprIR;
|
||||
readonly right: ExprIR;
|
||||
}
|
||||
| { readonly op: 'in'; readonly value: ExprIR; readonly set: ExprIR }
|
||||
| { readonly op: 'contains'; readonly set: ExprIR; readonly value: ExprIR }
|
||||
| { readonly op: 'and' | 'or'; readonly args: readonly ExprIR[] }
|
||||
| { readonly op: 'not'; readonly expr: ExprIR }
|
||||
| {
|
||||
readonly op: 'rel';
|
||||
readonly path: string;
|
||||
readonly subject: ExprIR;
|
||||
readonly resource?: ExprIR;
|
||||
}
|
||||
| { readonly op: 'exists'; readonly relation: string; readonly where?: ExprIR };
|
||||
|
||||
export interface PolicyTargetIR {
|
||||
readonly action: string;
|
||||
readonly resource?: string;
|
||||
}
|
||||
|
||||
export interface PolicyIR {
|
||||
readonly id: string;
|
||||
readonly effect: typeof PERMISSION_EFFECT_ALLOW | typeof PERMISSION_EFFECT_DENY;
|
||||
readonly priority: number;
|
||||
readonly target: PolicyTargetIR;
|
||||
readonly condition: ExprIR;
|
||||
readonly reason?: string;
|
||||
readonly code?: string;
|
||||
readonly obligations?: readonly ObligationIR[];
|
||||
readonly advice?: readonly AdviceIR[];
|
||||
readonly metadata?: Dict;
|
||||
}
|
||||
|
||||
export type EvalValue = true | false | 'unknown' | 'error';
|
||||
|
||||
export interface EvalResult {
|
||||
readonly value: EvalValue;
|
||||
readonly reason?: string;
|
||||
readonly error?: unknown;
|
||||
readonly dependencies: readonly DependencyKey[];
|
||||
}
|
||||
|
||||
export type DependencyKind = (typeof PERMISSION_DEPENDENCY_KINDS)[number];
|
||||
export type DependencyKey = `${DependencyKind}:${string}`;
|
||||
|
||||
export interface TraceEntry {
|
||||
readonly policy: string;
|
||||
readonly effect: typeof PERMISSION_EFFECT_ALLOW | typeof PERMISSION_EFFECT_DENY;
|
||||
readonly targetMatched: boolean;
|
||||
readonly condition?: ExprIR;
|
||||
readonly result?: EvalValue;
|
||||
readonly reason?: string;
|
||||
readonly dependencies: readonly DependencyKey[];
|
||||
}
|
||||
|
||||
export interface ExplainResult {
|
||||
readonly actor: SubjectRef;
|
||||
readonly action: string;
|
||||
readonly resource?: ResourceRef;
|
||||
readonly decision: PermissionDecision;
|
||||
readonly trace: readonly TraceEntry[];
|
||||
readonly dependencies: readonly DependencyKey[];
|
||||
}
|
||||
|
||||
export interface ResourceSchema {
|
||||
readonly actions: readonly string[];
|
||||
readonly attributes?: Dict;
|
||||
}
|
||||
|
||||
export interface ActorSchema {
|
||||
readonly actions?: readonly string[];
|
||||
readonly attributes?: Dict;
|
||||
}
|
||||
|
||||
export interface RelationSchema {
|
||||
readonly from: string;
|
||||
readonly to: string;
|
||||
readonly via?: string;
|
||||
readonly table?: string;
|
||||
readonly metadata?: Dict;
|
||||
}
|
||||
|
||||
export interface PermSchema {
|
||||
readonly actors?: Record<string, ActorSchema>;
|
||||
readonly resources: Record<string, ResourceSchema>;
|
||||
readonly relations?: Record<string, RelationSchema>;
|
||||
readonly context?: Dict;
|
||||
}
|
||||
|
||||
export type QueryPlanStrategy =
|
||||
| typeof PERMISSION_QUERY_STRATEGY_COMPILED
|
||||
| typeof PERMISSION_QUERY_STRATEGY_PARTIAL
|
||||
| typeof PERMISSION_QUERY_STRATEGY_NOT_COMPILABLE;
|
||||
|
||||
export interface QueryPlan {
|
||||
readonly strategy: QueryPlanStrategy;
|
||||
readonly target: string;
|
||||
readonly predicate?: unknown;
|
||||
readonly residualPolicies?: readonly PolicyIR[];
|
||||
readonly dependencies?: readonly DependencyKey[];
|
||||
readonly warnings?: readonly string[];
|
||||
}
|
||||
|
||||
export type RelationResult = boolean | 'unknown';
|
||||
|
||||
export interface RelationProvider {
|
||||
hasRelation(input: {
|
||||
readonly relation: string;
|
||||
readonly resource: ResourceRef;
|
||||
readonly subject: SubjectRef;
|
||||
readonly context: PermissionRequestContext;
|
||||
}): Promise<RelationResult> | RelationResult;
|
||||
|
||||
listSubjects?(input: {
|
||||
readonly relation: string;
|
||||
readonly resource: ResourceRef;
|
||||
readonly subjectType?: string;
|
||||
readonly context: PermissionRequestContext;
|
||||
}): Promise<readonly SubjectRef[]> | readonly SubjectRef[];
|
||||
|
||||
listResources?(input: {
|
||||
readonly relation: string;
|
||||
readonly subject: SubjectRef;
|
||||
readonly resourceType: string;
|
||||
readonly context: PermissionRequestContext;
|
||||
}): Promise<readonly ResourceRef[]> | readonly ResourceRef[];
|
||||
}
|
||||
|
||||
export interface AttributeProvider {
|
||||
getAttribute(input: {
|
||||
readonly root: PermissionRefRoot;
|
||||
readonly path: string;
|
||||
readonly context: PermissionRequestContext;
|
||||
}): Promise<unknown> | unknown;
|
||||
}
|
||||
|
||||
export interface SubscriptionProvider {
|
||||
subscribe(dependencies: readonly DependencyKey[], cb: () => void): () => void;
|
||||
}
|
||||
|
||||
export interface PermissionProviders {
|
||||
readonly attributes?: AttributeProvider;
|
||||
readonly relations?: RelationProvider;
|
||||
readonly subscriptions?: SubscriptionProvider;
|
||||
}
|
||||
|
||||
export interface QueryCompiler {
|
||||
readonly target: string;
|
||||
compile(input: {
|
||||
readonly schema: PermSchema;
|
||||
readonly policies: readonly PolicyIR[];
|
||||
readonly action: string;
|
||||
readonly actor: SubjectRef;
|
||||
readonly resourceType: string;
|
||||
readonly context?: Record<string, unknown>;
|
||||
}): Promise<QueryPlan> | QueryPlan;
|
||||
}
|
||||
|
||||
export interface PermissionRuntimeOptions {
|
||||
readonly schema: PermSchema;
|
||||
readonly policies: readonly PolicyIR[];
|
||||
readonly providers?: PermissionProviders;
|
||||
readonly compilers?: readonly QueryCompiler[];
|
||||
readonly defaultFallback?: PermissionFallback;
|
||||
}
|
||||
|
||||
export interface PermissionEvaluator {
|
||||
evaluate(expr: ExprIR, context: PermissionRequestContext): Promise<EvalResult>;
|
||||
}
|
||||
|
||||
export interface PermissionCheckInput {
|
||||
readonly actor: SubjectRef;
|
||||
readonly action: string;
|
||||
readonly resource?: ResourceRef;
|
||||
readonly context?: Record<string, unknown>;
|
||||
readonly requestId?: string;
|
||||
}
|
||||
|
||||
export interface PermissionWhatResult {
|
||||
readonly resource?: ResourceRef;
|
||||
readonly actions: Record<string, PermissionDecision>;
|
||||
}
|
||||
|
||||
export interface ReverseQueryResult {
|
||||
readonly action: string;
|
||||
readonly resource: ResourceRef;
|
||||
readonly subjectType: string;
|
||||
readonly sources: Array<{
|
||||
readonly type:
|
||||
| typeof PERMISSION_REVERSE_SOURCE_RELATION
|
||||
| typeof PERMISSION_REVERSE_SOURCE_RELATION_EXPANSION
|
||||
| typeof PERMISSION_REVERSE_SOURCE_POLICY;
|
||||
readonly path?: string;
|
||||
readonly policy?: string;
|
||||
readonly resource?: ResourceRef;
|
||||
}>;
|
||||
readonly constraints: Array<{
|
||||
readonly type:
|
||||
| typeof PERMISSION_REVERSE_CONSTRAINT_CONTEXT
|
||||
| typeof PERMISSION_REVERSE_CONSTRAINT_ATTRIBUTE
|
||||
| typeof PERMISSION_REVERSE_CONSTRAINT_UNKNOWN;
|
||||
readonly path?: string;
|
||||
readonly required?: unknown;
|
||||
readonly policy?: string;
|
||||
}>;
|
||||
readonly warnings: string[];
|
||||
}
|
||||
|
||||
export interface PermissionFilterBuilder {
|
||||
for(actor: SubjectRef): PermissionFilterBuilder;
|
||||
resource(resourceType: string): PermissionFilterBuilder;
|
||||
context(context: Record<string, unknown>): PermissionFilterBuilder;
|
||||
toPlan(target?: string): Promise<QueryPlan>;
|
||||
toPredicate(): Promise<(resource: ResourceRef) => Promise<boolean>>;
|
||||
}
|
||||
|
||||
export interface PermissionRuntime {
|
||||
check(input: PermissionCheckInput): Promise<PermissionDecision>;
|
||||
can(input: PermissionCheckInput): Promise<boolean>;
|
||||
assert(input: PermissionCheckInput): Promise<void>;
|
||||
explain(input: PermissionCheckInput): Promise<ExplainResult>;
|
||||
what(
|
||||
input: Omit<PermissionCheckInput, 'action'> & { readonly actions?: readonly string[] }
|
||||
): Promise<PermissionWhatResult>;
|
||||
who(
|
||||
input: PermissionCheckInput & { readonly resource: ResourceRef; readonly subjectType?: string }
|
||||
): Promise<ReverseQueryResult>;
|
||||
filter(action: string): PermissionFilterBuilder;
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
export const DEFAULT_BACKOFF_MIN_DELAY_MS = 500;
|
||||
export const DEFAULT_BACKOFF_MAX_DELAY_MS = 15_000;
|
||||
export const DEFAULT_BACKOFF_FACTOR = 1.8;
|
||||
export const DEFAULT_BACKOFF_JITTER_MS = 500;
|
||||
|
||||
export interface BackoffOptions {
|
||||
readonly minDelayMs?: number;
|
||||
readonly maxDelayMs?: number;
|
||||
readonly factor?: number;
|
||||
readonly jitterMs?: number;
|
||||
}
|
||||
|
||||
export function computeBackoffDelay(
|
||||
attempt: number,
|
||||
options: BackoffOptions = {},
|
||||
random: () => number = Math.random
|
||||
): number {
|
||||
const minDelayMs = options.minDelayMs ?? DEFAULT_BACKOFF_MIN_DELAY_MS;
|
||||
const maxDelayMs = options.maxDelayMs ?? DEFAULT_BACKOFF_MAX_DELAY_MS;
|
||||
const factor = options.factor ?? DEFAULT_BACKOFF_FACTOR;
|
||||
const jitterMs = options.jitterMs ?? DEFAULT_BACKOFF_JITTER_MS;
|
||||
|
||||
const safeAttempt = Number.isFinite(attempt) && attempt >= 0 ? attempt : 0;
|
||||
const base = Math.min(maxDelayMs, minDelayMs * Math.pow(factor, safeAttempt));
|
||||
const jitter = jitterMs > 0 ? (random() * 2 - 1) * jitterMs : 0;
|
||||
|
||||
const delay = base + jitter;
|
||||
if (delay < 0) return 0;
|
||||
if (delay > maxDelayMs) return maxDelayMs;
|
||||
return delay;
|
||||
}
|
||||
@ -1 +1,2 @@
|
||||
export * from './debounce.ts';
|
||||
export * from './backoff.ts';
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
# perm-authz-ts
|
||||
|
||||
Implementación base de un sistema de autorización tipado para aplicaciones TypeScript con dos runtimes:
|
||||
|
||||
- `@perm/core`: tipos, schema, builders e IR. No depende de DB, DOM ni framework.
|
||||
- `@perm/server`: runtime autoritativo para backend: `check`, `assert`, `explain`, `filter`, adapters y endpoints HTTP.
|
||||
- `@perm/client`: cliente remoto para SPA/SSR: snapshot, cache, batch checks, invalidación.
|
||||
- `@perm/svelte`: integración Svelte/SvelteKit: context, stores y componente `<Can>`.
|
||||
|
||||
La regla de seguridad es: el servidor decide; el cliente refleja. El cliente sirve para UX y reactividad, no como frontera de seguridad.
|
||||
|
||||
## Ejemplo rápido
|
||||
|
||||
```ts
|
||||
import { allow, and, attr, definePolicies, definePermSchema, rel, actor } from "@perm/core";
|
||||
import { createPermissions } from "@perm/server";
|
||||
|
||||
const schema = definePermSchema({
|
||||
resources: {
|
||||
post: { actions: ["read", "update", "delete"] }
|
||||
},
|
||||
actors: {
|
||||
user: { actions: [] }
|
||||
},
|
||||
relations: {
|
||||
"post.owner": { from: "post", to: "user" }
|
||||
}
|
||||
});
|
||||
|
||||
const policies = definePolicies(schema, [
|
||||
allow("post.read").when(attr("post.visibility").eq("public")),
|
||||
allow("post.update").when(rel("post.owner").is(actor())),
|
||||
allow("post.delete").when(and(rel("post.owner").is(actor()), attr("actor.role").eq("admin")))
|
||||
]);
|
||||
|
||||
const Perm = createPermissions({
|
||||
schema,
|
||||
policies,
|
||||
providers: {
|
||||
relations: {
|
||||
async hasRelation({ relation, resource, subject }) {
|
||||
if (relation === "post.owner") return resource.ownerId === subject.id;
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Perm.assert({ actor: { type: "user", id: "u1" }, action: "post.update", resource: post });
|
||||
```
|
||||
|
||||
## Estado
|
||||
|
||||
Es una primera implementación funcional de arquitectura, no una librería productiva cerrada. Incluye el core, el evaluador, el combinador deny-overrides, cliente remoto, integración Svelte y compilers base. Faltan adapters productivos para Prisma/Drizzle/OpenFGA/SpiceDB, persistencia de auditoría y hardening completo de seguridad.
|
||||
@ -0,0 +1,91 @@
|
||||
import { actor, allow, and, attr, audit, definePermSchema, definePolicies, deny, rel } from "@perm/core";
|
||||
import { createPermissions, createSqlCompiler } from "@perm/server";
|
||||
|
||||
export const schema = definePermSchema({
|
||||
actors: {
|
||||
user: { attributes: { status: "string", role: "string" } }
|
||||
},
|
||||
resources: {
|
||||
post: {
|
||||
actions: ["read", "update", "delete", "publish"],
|
||||
attributes: {
|
||||
visibility: "string",
|
||||
ownerId: "string",
|
||||
teamId: "string",
|
||||
status: "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
relations: {
|
||||
"post.owner": { from: "post", to: "user" },
|
||||
"post.team.member": { from: "post", to: "user" },
|
||||
"post.team.admin": { from: "post", to: "user" }
|
||||
},
|
||||
context: {
|
||||
risk: { mfa: "boolean" }
|
||||
}
|
||||
});
|
||||
|
||||
export const policies = definePolicies(schema, [
|
||||
deny("post.*")
|
||||
.id("post.deny.suspended")
|
||||
.priority(1000)
|
||||
.when(attr("actor.status").eq("suspended"))
|
||||
.because("Suspended users cannot access posts", "user_suspended"),
|
||||
|
||||
allow("post.read")
|
||||
.id("post.read.public-or-member")
|
||||
.when(
|
||||
// A minimal starter. In production, keep relations backed by DB/OpenFGA/SpiceDB.
|
||||
attr("post.visibility").eq("public")
|
||||
),
|
||||
|
||||
allow("post.update")
|
||||
.id("post.update.owner")
|
||||
.when(
|
||||
and(
|
||||
rel("post.owner").is(actor()),
|
||||
attr("post.status").notEq("archived")
|
||||
)
|
||||
),
|
||||
|
||||
allow("post.publish")
|
||||
.id("post.publish.admin-with-mfa")
|
||||
.when(
|
||||
and(
|
||||
rel("post.team.admin").has(actor()),
|
||||
attr("context.risk.mfa").eq(true)
|
||||
)
|
||||
)
|
||||
.oblige(audit("post.publish", "medium"))
|
||||
]);
|
||||
|
||||
export const Perm = createPermissions({
|
||||
schema,
|
||||
policies,
|
||||
providers: {
|
||||
relations: {
|
||||
async hasRelation({ relation, resource, subject }) {
|
||||
if (relation === "post.owner") return resource.ownerId === subject.id;
|
||||
if (relation === "post.team.admin") return subject.role === "admin";
|
||||
if (relation === "post.team.member") return Array.isArray(subject.teamIds) && subject.teamIds.includes(resource.teamId);
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
},
|
||||
compilers: [
|
||||
createSqlCompiler({
|
||||
resourceAlias: "post",
|
||||
relation({ relation, resourceAlias, actor, param }) {
|
||||
if (relation === "post.owner") return `${resourceAlias}.owner_id = ${param(actor.id)}`;
|
||||
if (relation === "post.team.member") {
|
||||
return `EXISTS (SELECT 1 FROM team_members tm WHERE tm.team_id = ${resourceAlias}.team_id AND tm.user_id = ${param(actor.id)})`;
|
||||
}
|
||||
if (relation === "post.team.admin") {
|
||||
return `EXISTS (SELECT 1 FROM team_members tm WHERE tm.team_id = ${resourceAlias}.team_id AND tm.user_id = ${param(actor.id)} AND tm.role = 'admin')`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
})
|
||||
]
|
||||
});
|
||||
@ -0,0 +1,13 @@
|
||||
import { Perm } from "./permissions.js";
|
||||
|
||||
export async function updatePostRoute({ user, post, body }: { user: any; post: any; body: any }) {
|
||||
await Perm.assert({ actor: user, action: "post.update", resource: post });
|
||||
// db.posts.update(post.id, body)
|
||||
return { ok: true, updated: body };
|
||||
}
|
||||
|
||||
export async function listPostsRoute({ user }: { user: any }) {
|
||||
const plan = await Perm.filter("post.read").for(user).resource("post").toPlan("sql");
|
||||
// db.select().from(posts).where(plan.predicate.sql, plan.predicate.params)
|
||||
return plan;
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import Can from "@perm/svelte/Can.svelte";
|
||||
export let post;
|
||||
</script>
|
||||
|
||||
<Can action="post.update" resource={post}>
|
||||
<button>Edit</button>
|
||||
<svelte:fragment slot="fallback">
|
||||
<button disabled>Edit</button>
|
||||
</svelte:fragment>
|
||||
</Can>
|
||||
|
||||
<Can action="post.delete" resource={post}>
|
||||
<button>Delete</button>
|
||||
</Can>
|
||||
@ -0,0 +1,6 @@
|
||||
import { createSveltePermissions } from "@perm/svelte";
|
||||
|
||||
export const perm = createSveltePermissions({
|
||||
endpoint: "/api/authz",
|
||||
cacheTtlMs: 30_000
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue