@relayfile/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Storage adapter interface.
3
+ *
4
+ * All core logic functions accept a storage adapter — they never do I/O directly.
5
+ * Implementors provide the actual storage (DO SQLite, Go in-memory, Postgres, etc.).
6
+ *
7
+ * Methods return plain data. Errors are signaled via return values, not exceptions.
8
+ */
9
+ export interface FileRow {
10
+ path: string;
11
+ revision: string;
12
+ contentType: string;
13
+ /** Content body. May be inline or a ref key depending on storage backend. */
14
+ content: string;
15
+ encoding: string;
16
+ provider: string;
17
+ lastEditedAt: string;
18
+ semantics: FileSemantics;
19
+ }
20
+ export interface FileSemantics {
21
+ properties?: Record<string, string>;
22
+ relations?: string[];
23
+ permissions?: string[];
24
+ comments?: string[];
25
+ }
26
+ export interface EventRow {
27
+ eventId: string;
28
+ type: string;
29
+ path: string;
30
+ revision: string;
31
+ origin: string;
32
+ provider: string;
33
+ correlationId: string;
34
+ timestamp: string;
35
+ }
36
+ export interface OperationRow {
37
+ opId: string;
38
+ path: string;
39
+ revision: string;
40
+ action: string;
41
+ provider: string;
42
+ status: string;
43
+ attemptCount: number;
44
+ lastError: string | null;
45
+ nextAttemptAt: string | null;
46
+ correlationId: string;
47
+ }
48
+ export interface WritebackItem {
49
+ id: string;
50
+ workspaceId: string;
51
+ path: string;
52
+ revision: string;
53
+ correlationId: string;
54
+ }
55
+ export interface EnvelopeRow {
56
+ envelopeId: string;
57
+ workspaceId: string;
58
+ provider: string;
59
+ deliveryId: string;
60
+ deliveryIds?: string[];
61
+ receivedAt: string;
62
+ headers?: Record<string, string>;
63
+ payload: Record<string, unknown>;
64
+ correlationId: string;
65
+ status: string;
66
+ attemptCount: number;
67
+ lastError: string | null;
68
+ }
69
+ export interface PaginationOptions {
70
+ cursor?: string;
71
+ limit?: number;
72
+ }
73
+ export interface Paginated<T> {
74
+ items: T[];
75
+ nextCursor: string | null;
76
+ }
77
+ export interface EnvelopeQueryOptions extends PaginationOptions {
78
+ workspaceId?: string;
79
+ provider?: string;
80
+ status?: string;
81
+ }
82
+ export interface StorageAdapter {
83
+ getFile(path: string): FileRow | null;
84
+ listFiles(): FileRow[];
85
+ putFile(file: FileRow): void;
86
+ deleteFile(path: string): void;
87
+ loadFileContent?(file: FileRow): {
88
+ content: string;
89
+ encoding?: string;
90
+ } | string;
91
+ appendEvent(event: EventRow): void;
92
+ listEvents(options: PaginationOptions & {
93
+ provider?: string;
94
+ }): Paginated<EventRow>;
95
+ getRecentEvents(limit: number): EventRow[];
96
+ getOperation(opId: string): OperationRow | null;
97
+ putOperation(op: OperationRow): void;
98
+ listOperations(options: PaginationOptions & {
99
+ status?: string;
100
+ action?: string;
101
+ provider?: string;
102
+ }): Paginated<OperationRow>;
103
+ nextRevision(): string;
104
+ nextOperationId(): string;
105
+ nextEventId(): string;
106
+ enqueueWriteback(item: WritebackItem): void;
107
+ getPendingWritebacks(): WritebackItem[];
108
+ getEnvelopeByDelivery?(workspaceId: string, provider: string, deliveryId: string): EnvelopeRow | null;
109
+ putEnvelope?(envelope: EnvelopeRow): void;
110
+ putEnvelopeDeliveryAlias?(workspaceId: string, provider: string, deliveryId: string, envelopeId: string): void;
111
+ listEnvelopes?(options: EnvelopeQueryOptions): Paginated<EnvelopeRow>;
112
+ getWorkspaceId(): string;
113
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Storage adapter interface.
3
+ *
4
+ * All core logic functions accept a storage adapter — they never do I/O directly.
5
+ * Implementors provide the actual storage (DO SQLite, Go in-memory, Postgres, etc.).
6
+ *
7
+ * Methods return plain data. Errors are signaled via return values, not exceptions.
8
+ */
9
+ export {};
package/dist/tree.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Tree listing and directory traversal.
3
+ *
4
+ * Extract from workspace.ts:
5
+ * - listTree() / handleListTree()
6
+ * - directory inference from file paths
7
+ * - depth limiting
8
+ * - cursor pagination
9
+ * - ancestor/descendant path utilities
10
+ */
11
+ import type { StorageAdapter, PaginationOptions } from "./storage.js";
12
+ import { type TokenClaims } from "./acl.js";
13
+ export interface TreeEntry {
14
+ path: string;
15
+ type: "file" | "dir";
16
+ revision?: string;
17
+ provider?: string;
18
+ size?: number;
19
+ updatedAt?: string;
20
+ propertyCount?: number;
21
+ relationCount?: number;
22
+ commentCount?: number;
23
+ }
24
+ export interface TreeResult {
25
+ path: string;
26
+ entries: TreeEntry[];
27
+ nextCursor: string | null;
28
+ }
29
+ export interface ListTreeOptions extends PaginationOptions {
30
+ path?: string;
31
+ depth?: number;
32
+ }
33
+ export declare function listTree(storage: StorageAdapter, options: ListTreeOptions, claims: TokenClaims | null): TreeResult;
34
+ export declare function ancestorDirectories(path: string): string[];
35
+ export declare function joinPath(dir: string, filename: string): string;
package/dist/tree.js ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Tree listing and directory traversal.
3
+ *
4
+ * Extract from workspace.ts:
5
+ * - listTree() / handleListTree()
6
+ * - directory inference from file paths
7
+ * - depth limiting
8
+ * - cursor pagination
9
+ * - ancestor/descendant path utilities
10
+ */
11
+ import { filePermissionAllows, resolveFilePermissions, } from "./acl.js";
12
+ export function listTree(storage, options, claims) {
13
+ const base = normalizePath(options.path ?? "/");
14
+ const maxDepth = options.depth && options.depth > 0 ? options.depth : 1;
15
+ const workspaceId = storage.getWorkspaceId();
16
+ const entryMap = new Map();
17
+ for (const row of storage.listFiles()) {
18
+ const filePath = normalizePath(row.path);
19
+ if (filePath === base || !isWithinBase(filePath, base)) {
20
+ continue;
21
+ }
22
+ if (!filePermissionAllows(resolveFilePermissions(storage, filePath, true), workspaceId, claims)) {
23
+ continue;
24
+ }
25
+ const rest = filePath.slice(base === "/" ? 1 : base.length + 1);
26
+ if (!rest) {
27
+ continue;
28
+ }
29
+ const parts = rest.split("/").filter(Boolean);
30
+ for (let level = 1; level <= Math.min(parts.length, maxDepth); level += 1) {
31
+ const childPath = joinPath(base, parts.slice(0, level).join("/"));
32
+ if (level === parts.length) {
33
+ entryMap.set(childPath, {
34
+ path: childPath,
35
+ type: "file",
36
+ revision: row.revision,
37
+ provider: row.provider,
38
+ size: encodedSize(row.content, row.encoding),
39
+ updatedAt: row.lastEditedAt,
40
+ propertyCount: Object.keys(row.semantics.properties ?? {}).length,
41
+ relationCount: row.semantics.relations?.length ?? 0,
42
+ commentCount: row.semantics.comments?.length ?? 0,
43
+ });
44
+ }
45
+ else if (!entryMap.has(childPath)) {
46
+ entryMap.set(childPath, {
47
+ path: childPath,
48
+ type: "dir",
49
+ revision: "dir",
50
+ });
51
+ }
52
+ }
53
+ }
54
+ let entries = Array.from(entryMap.values()).sort((left, right) => left.path.localeCompare(right.path));
55
+ if (options.cursor) {
56
+ const index = entries.findIndex((entry) => entry.path === options.cursor);
57
+ if (index >= 0) {
58
+ entries = entries.slice(index + 1);
59
+ }
60
+ }
61
+ return {
62
+ path: base,
63
+ entries,
64
+ nextCursor: null,
65
+ };
66
+ }
67
+ export function ancestorDirectories(path) {
68
+ const normalized = normalizePath(path);
69
+ const parts = normalized.split("/").filter(Boolean);
70
+ const dirs = ["/"];
71
+ let current = "";
72
+ for (let index = 0; index < Math.max(0, parts.length - 1); index += 1) {
73
+ current = joinPath(current || "/", parts[index] ?? "");
74
+ dirs.push(current);
75
+ }
76
+ return dirs;
77
+ }
78
+ export function joinPath(dir, filename) {
79
+ const normalizedDir = normalizePath(dir);
80
+ return normalizedDir === "/"
81
+ ? normalizePath(`/${filename}`)
82
+ : normalizePath(`${normalizedDir}/${filename}`);
83
+ }
84
+ function normalizePath(path) {
85
+ const trimmed = path.trim();
86
+ if (!trimmed) {
87
+ return "/";
88
+ }
89
+ const prefixed = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
90
+ return prefixed.length > 1 ? prefixed.replace(/\/+$/, "") : "/";
91
+ }
92
+ function isWithinBase(path, base) {
93
+ return base === "/" ? path.startsWith("/") : path.startsWith(`${base}/`);
94
+ }
95
+ function encodedSize(content, encoding) {
96
+ if (encoding === "base64") {
97
+ try {
98
+ return Uint8Array.from(atob(content), (char) => char.charCodeAt(0))
99
+ .byteLength;
100
+ }
101
+ catch {
102
+ return content.length;
103
+ }
104
+ }
105
+ return new TextEncoder().encode(content).byteLength;
106
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Shared utility functions used across core modules.
3
+ *
4
+ * Centralises helpers that were previously copy-pasted in
5
+ * files.ts, query.ts, tree.ts, operations.ts, acl.ts, and writeback.ts.
6
+ */
7
+ /**
8
+ * Normalize a file path: ensure leading slash, strip trailing slashes,
9
+ * and resolve `.` and `..` segments to prevent path traversal attacks.
10
+ */
11
+ export declare function normalizePath(path: string): string;
package/dist/utils.js ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Shared utility functions used across core modules.
3
+ *
4
+ * Centralises helpers that were previously copy-pasted in
5
+ * files.ts, query.ts, tree.ts, operations.ts, acl.ts, and writeback.ts.
6
+ */
7
+ /**
8
+ * Normalize a file path: ensure leading slash, strip trailing slashes,
9
+ * and resolve `.` and `..` segments to prevent path traversal attacks.
10
+ */
11
+ export function normalizePath(path) {
12
+ const trimmed = path.trim();
13
+ if (!trimmed) {
14
+ return "/";
15
+ }
16
+ const prefixed = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
17
+ // Split into segments and resolve . and .. to prevent path traversal
18
+ const segments = [];
19
+ for (const segment of prefixed.split("/")) {
20
+ if (segment === "." || segment === "") {
21
+ continue;
22
+ }
23
+ if (segment === "..") {
24
+ segments.pop();
25
+ }
26
+ else {
27
+ segments.push(segment);
28
+ }
29
+ }
30
+ const resolved = "/" + segments.join("/");
31
+ return resolved.length > 1 ? resolved.replace(/\/+$/, "") : "/";
32
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Webhook envelope normalization and processing.
3
+ *
4
+ * Extract from workspace.ts:
5
+ * - generic webhook ingestion
6
+ * - envelope normalization
7
+ * - deduplication by delivery_id
8
+ * - coalescing within time window
9
+ * - suppression for loop prevention
10
+ * - stale event filtering
11
+ * - file materialization from webhook data
12
+ *
13
+ * This module stays pure. It only operates over plain storage records and
14
+ * optional callbacks for suppression/staleness policy.
15
+ */
16
+ import type { StorageAdapter, EnvelopeRow, EnvelopeQueryOptions, Paginated, FileSemantics } from "./storage.js";
17
+ export interface IngestWebhookInput {
18
+ provider: string;
19
+ eventType?: string;
20
+ path?: string;
21
+ data?: Record<string, unknown>;
22
+ deliveryId?: string;
23
+ timestamp?: string;
24
+ headers?: Record<string, string>;
25
+ correlationId?: string;
26
+ }
27
+ export interface IngestResult {
28
+ status: string;
29
+ envelopeId: string;
30
+ correlationId: string;
31
+ }
32
+ export interface EnvelopeEvent {
33
+ type: string;
34
+ path: string;
35
+ timestamp: string;
36
+ content?: string;
37
+ contentType?: string;
38
+ encoding?: string;
39
+ semantics?: FileSemantics;
40
+ data?: Record<string, unknown>;
41
+ }
42
+ export interface ApplyEnvelopeOptions {
43
+ now?: () => string;
44
+ shouldSuppress?: (envelope: EnvelopeRow, event: EnvelopeEvent) => boolean;
45
+ isStale?: (envelope: EnvelopeRow, event: EnvelopeEvent) => boolean;
46
+ /**
47
+ * Optional ACL enforcement callback. When provided, called before file
48
+ * writes with the target path. Return `true` to allow, `false` to deny.
49
+ * If denied, the envelope is marked as "ignored".
50
+ */
51
+ isPathWriteAllowed?: (path: string) => boolean;
52
+ }
53
+ export interface ApplyEnvelopeResult {
54
+ status: "processed" | "ignored" | "suppressed" | "stale" | "rejected";
55
+ eventType: string | null;
56
+ path: string | null;
57
+ revision: string | null;
58
+ reason?: string;
59
+ }
60
+ export interface IngestWebhookOptions {
61
+ now?: () => string;
62
+ generateEnvelopeId?: () => string;
63
+ coalesceWindowMs?: number;
64
+ /**
65
+ * Optional signature verification callback. When provided, called with the
66
+ * raw input before processing. Return `true` if the payload signature is
67
+ * valid, `false` to reject the webhook. Callers should implement
68
+ * HMAC/RSA verification in this callback at the HTTP handler layer.
69
+ */
70
+ signatureVerifier?: (input: IngestWebhookInput) => boolean;
71
+ }
72
+ export interface WebhookStorageAdapter extends StorageAdapter {
73
+ getEnvelopeByDelivery(workspaceId: string, provider: string, deliveryId: string): EnvelopeRow | null;
74
+ putEnvelope(envelope: EnvelopeRow): void;
75
+ putEnvelopeDeliveryAlias?(workspaceId: string, provider: string, deliveryId: string, envelopeId: string): void;
76
+ listEnvelopes(options: EnvelopeQueryOptions): Paginated<EnvelopeRow>;
77
+ }
78
+ export declare function ingestWebhook(storage: StorageAdapter, input: IngestWebhookInput, options?: IngestWebhookOptions): IngestResult;
79
+ export declare function normalizeEnvelope(input: IngestWebhookInput, now?: () => string): Partial<EnvelopeRow>;
80
+ export declare function normalizeEnvelopeEvent(envelope: Pick<EnvelopeRow, "payload" | "receivedAt">): EnvelopeEvent | null;
81
+ export declare function normalizeEnvelopePath(envelope: Pick<EnvelopeRow, "payload">): string | null;
82
+ export declare function applyWebhookEnvelope(storage: StorageAdapter, envelope: EnvelopeRow, options?: ApplyEnvelopeOptions): ApplyEnvelopeResult;