@linkshell/gateway 0.5.2 → 0.6.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.
Files changed (109) hide show
  1. package/Dockerfile +8 -22
  2. package/README.md +99 -44
  3. package/dist/gateway/src/accounts.js.map +1 -0
  4. package/dist/gateway/src/index.d.ts +4 -1
  5. package/dist/gateway/src/index.js +4 -863
  6. package/dist/gateway/src/index.js.map +1 -1
  7. package/dist/gateway/src/main.js +57 -0
  8. package/dist/gateway/src/main.js.map +1 -0
  9. package/dist/gateway/src/rate-limit.d.ts +22 -0
  10. package/dist/gateway/src/rate-limit.js +66 -0
  11. package/dist/gateway/src/rate-limit.js.map +1 -0
  12. package/dist/gateway/src/relay.d.ts +84 -5
  13. package/dist/gateway/src/relay.js +402 -529
  14. package/dist/gateway/src/relay.js.map +1 -1
  15. package/dist/gateway/src/serve.d.ts +34 -0
  16. package/dist/gateway/src/serve.js +102 -0
  17. package/dist/gateway/src/serve.js.map +1 -0
  18. package/dist/{gateway-v2 → gateway}/src/store.d.ts +5 -0
  19. package/dist/{gateway-v2 → gateway}/src/store.js +7 -0
  20. package/dist/gateway/src/store.js.map +1 -0
  21. package/dist/gateway/src/subscription.d.ts +18 -0
  22. package/dist/gateway/src/subscription.js +46 -0
  23. package/dist/gateway/src/subscription.js.map +1 -0
  24. package/dist/gateway/tsconfig.tsbuildinfo +1 -1
  25. package/dist/wire/src/direct.d.ts +22 -0
  26. package/dist/wire/src/direct.js +37 -0
  27. package/dist/wire/src/direct.js.map +1 -0
  28. package/dist/wire/src/index.d.ts +1 -0
  29. package/dist/wire/src/index.js +1 -0
  30. package/dist/wire/src/index.js.map +1 -1
  31. package/dist/wire/src/model.d.ts +387 -6
  32. package/dist/wire/src/model.js +74 -0
  33. package/dist/wire/src/model.js.map +1 -1
  34. package/dist/wire/src/relay.d.ts +8 -8
  35. package/dist/wire/src/rpc.d.ts +2524 -200
  36. package/dist/wire/src/rpc.js +77 -4
  37. package/dist/wire/src/rpc.js.map +1 -1
  38. package/dist/wire/src/updates.d.ts +390 -34
  39. package/dist/wire/src/updates.js +11 -1
  40. package/dist/wire/src/updates.js.map +1 -1
  41. package/package.json +12 -12
  42. package/src/accounts.ts +32 -0
  43. package/src/index.ts +4 -997
  44. package/src/main.ts +65 -0
  45. package/src/rate-limit.ts +72 -0
  46. package/src/relay.ts +435 -620
  47. package/src/serve.ts +130 -0
  48. package/src/store.ts +135 -0
  49. package/src/subscription.ts +51 -0
  50. package/dist/gateway/src/auth-middleware.d.ts +0 -44
  51. package/dist/gateway/src/auth-middleware.js +0 -204
  52. package/dist/gateway/src/auth-middleware.js.map +0 -1
  53. package/dist/gateway/src/cors.d.ts +0 -2
  54. package/dist/gateway/src/cors.js +0 -7
  55. package/dist/gateway/src/cors.js.map +0 -1
  56. package/dist/gateway/src/embedded.d.ts +0 -21
  57. package/dist/gateway/src/embedded.js +0 -438
  58. package/dist/gateway/src/embedded.js.map +0 -1
  59. package/dist/gateway/src/host-auth.d.ts +0 -32
  60. package/dist/gateway/src/host-auth.js +0 -78
  61. package/dist/gateway/src/host-auth.js.map +0 -1
  62. package/dist/gateway/src/pairings.d.ts +0 -38
  63. package/dist/gateway/src/pairings.js +0 -119
  64. package/dist/gateway/src/pairings.js.map +0 -1
  65. package/dist/gateway/src/presence.d.ts +0 -28
  66. package/dist/gateway/src/presence.js +0 -92
  67. package/dist/gateway/src/presence.js.map +0 -1
  68. package/dist/gateway/src/sessions.d.ts +0 -113
  69. package/dist/gateway/src/sessions.js +0 -352
  70. package/dist/gateway/src/sessions.js.map +0 -1
  71. package/dist/gateway/src/state-store.d.ts +0 -21
  72. package/dist/gateway/src/state-store.js +0 -103
  73. package/dist/gateway/src/state-store.js.map +0 -1
  74. package/dist/gateway/src/static-web.d.ts +0 -15
  75. package/dist/gateway/src/static-web.js +0 -129
  76. package/dist/gateway/src/static-web.js.map +0 -1
  77. package/dist/gateway/src/tokens.d.ts +0 -19
  78. package/dist/gateway/src/tokens.js +0 -151
  79. package/dist/gateway/src/tokens.js.map +0 -1
  80. package/dist/gateway/src/tunnel.d.ts +0 -61
  81. package/dist/gateway/src/tunnel.js +0 -576
  82. package/dist/gateway/src/tunnel.js.map +0 -1
  83. package/dist/gateway-v2/src/accounts.js.map +0 -1
  84. package/dist/gateway-v2/src/index.d.ts +0 -3
  85. package/dist/gateway-v2/src/index.js +0 -4
  86. package/dist/gateway-v2/src/index.js.map +0 -1
  87. package/dist/gateway-v2/src/main.js +0 -17
  88. package/dist/gateway-v2/src/main.js.map +0 -1
  89. package/dist/gateway-v2/src/server.d.ts +0 -84
  90. package/dist/gateway-v2/src/server.js +0 -426
  91. package/dist/gateway-v2/src/server.js.map +0 -1
  92. package/dist/gateway-v2/src/store.js.map +0 -1
  93. package/dist/shared-protocol/src/index.d.ts +0 -17029
  94. package/dist/shared-protocol/src/index.js +0 -1064
  95. package/dist/shared-protocol/src/index.js.map +0 -1
  96. package/src/auth-middleware.ts +0 -266
  97. package/src/cors.ts +0 -8
  98. package/src/embedded.ts +0 -526
  99. package/src/host-auth.ts +0 -88
  100. package/src/pairings.ts +0 -133
  101. package/src/presence.ts +0 -118
  102. package/src/sessions.ts +0 -432
  103. package/src/state-store.ts +0 -140
  104. package/src/static-web.ts +0 -133
  105. package/src/tokens.ts +0 -159
  106. package/src/tunnel.ts +0 -644
  107. /package/dist/{gateway-v2 → gateway}/src/accounts.d.ts +0 -0
  108. /package/dist/{gateway-v2 → gateway}/src/accounts.js +0 -0
  109. /package/dist/{gateway-v2 → gateway}/src/main.d.ts +0 -0
@@ -1,140 +0,0 @@
1
- export interface StoredTokenRecord {
2
- token: string;
3
- sessionIds: string[];
4
- createdAt: number;
5
- lastUsedAt: number;
6
- }
7
-
8
- export interface StoredPairingRecord {
9
- sessionId: string;
10
- pairingCode: string;
11
- expiresAt: number;
12
- claimed: boolean;
13
- }
14
-
15
- export interface GatewayStateStore {
16
- loadTokens(): Promise<StoredTokenRecord[]>;
17
- saveToken(record: StoredTokenRecord): Promise<void>;
18
- deleteToken(token: string): Promise<void>;
19
- loadPairings(): Promise<StoredPairingRecord[]>;
20
- savePairing(record: StoredPairingRecord): Promise<void>;
21
- deletePairing(pairingCode: string): Promise<void>;
22
- }
23
-
24
- const TOKEN_TABLE = process.env.SUPABASE_GATEWAY_TOKEN_TABLE ?? "linkshell_gateway_tokens";
25
- const PAIRING_TABLE = process.env.SUPABASE_GATEWAY_PAIRING_TABLE ?? "linkshell_gateway_pairings";
26
- const STORE_TIMEOUT_MS = Number(process.env.SUPABASE_STATE_TIMEOUT_MS ?? 3_000);
27
-
28
- function msToIso(ms: number): string {
29
- return new Date(ms).toISOString();
30
- }
31
-
32
- function isoToMs(value: unknown): number {
33
- if (typeof value !== "string") return Date.now();
34
- const parsed = Date.parse(value);
35
- return Number.isNaN(parsed) ? Date.now() : parsed;
36
- }
37
-
38
- export function createSupabaseStateStore(): GatewayStateStore | undefined {
39
- const url = process.env.SUPABASE_URL?.replace(/\/+$/, "");
40
- const key = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_ANON_KEY;
41
- if (!url || !key) return undefined;
42
-
43
- const headers = {
44
- apikey: key,
45
- Authorization: `Bearer ${key}`,
46
- "Content-Type": "application/json",
47
- };
48
-
49
- async function request<T>(path: string, init?: RequestInit): Promise<T> {
50
- const res = await fetch(`${url}/rest/v1/${path}`, {
51
- ...init,
52
- signal: init?.signal ?? AbortSignal.timeout(STORE_TIMEOUT_MS),
53
- headers: {
54
- ...headers,
55
- ...(init?.headers ?? {}),
56
- },
57
- });
58
- if (!res.ok) {
59
- const body = await res.text().catch(() => "");
60
- throw new Error(`Supabase state store ${res.status}: ${body || res.statusText}`);
61
- }
62
- if (res.status === 204) return undefined as T;
63
- // Writes (POST upsert / DELETE) can return 2xx with an EMPTY body depending
64
- // on PostgREST's Prefer handling — calling res.json() on that throws
65
- // "Unexpected end of JSON input". Read as text and only parse when non-empty
66
- // so writes resolve to undefined cleanly; reads (SELECT) always return a
67
- // JSON array ("[]" at minimum), so they still parse.
68
- const text = await res.text();
69
- if (!text.trim()) return undefined as T;
70
- return JSON.parse(text) as T;
71
- }
72
-
73
- return {
74
- async loadTokens() {
75
- const rows = await request<Array<Record<string, unknown>>>(
76
- `${TOKEN_TABLE}?select=token,session_ids,created_at,last_used_at`,
77
- );
78
- return rows.map((row) => ({
79
- token: String(row.token ?? ""),
80
- sessionIds: Array.isArray(row.session_ids)
81
- ? row.session_ids.map(String)
82
- : [],
83
- createdAt: isoToMs(row.created_at),
84
- lastUsedAt: isoToMs(row.last_used_at),
85
- })).filter((record) => record.token);
86
- },
87
- async saveToken(record) {
88
- await request(
89
- `${TOKEN_TABLE}?on_conflict=token`,
90
- {
91
- method: "POST",
92
- headers: { Prefer: "return=minimal,resolution=merge-duplicates" },
93
- body: JSON.stringify({
94
- token: record.token,
95
- session_ids: record.sessionIds,
96
- created_at: msToIso(record.createdAt),
97
- last_used_at: msToIso(record.lastUsedAt),
98
- }),
99
- },
100
- );
101
- },
102
- async deleteToken(token) {
103
- await request(`${TOKEN_TABLE}?token=eq.${encodeURIComponent(token)}`, {
104
- method: "DELETE",
105
- });
106
- },
107
- async loadPairings() {
108
- const rows = await request<Array<Record<string, unknown>>>(
109
- `${PAIRING_TABLE}?select=pairing_code,session_id,expires_at,claimed`,
110
- );
111
- return rows.map((row) => ({
112
- pairingCode: String(row.pairing_code ?? ""),
113
- sessionId: String(row.session_id ?? ""),
114
- expiresAt: isoToMs(row.expires_at),
115
- claimed: row.claimed === true,
116
- })).filter((record) => record.pairingCode && record.sessionId);
117
- },
118
- async savePairing(record) {
119
- await request(
120
- `${PAIRING_TABLE}?on_conflict=pairing_code`,
121
- {
122
- method: "POST",
123
- headers: { Prefer: "return=minimal,resolution=merge-duplicates" },
124
- body: JSON.stringify({
125
- pairing_code: record.pairingCode,
126
- session_id: record.sessionId,
127
- expires_at: msToIso(record.expiresAt),
128
- claimed: record.claimed,
129
- }),
130
- },
131
- );
132
- },
133
- async deletePairing(pairingCode) {
134
- await request(
135
- `${PAIRING_TABLE}?pairing_code=eq.${encodeURIComponent(pairingCode)}`,
136
- { method: "DELETE" },
137
- );
138
- },
139
- };
140
- }
package/src/static-web.ts DELETED
@@ -1,133 +0,0 @@
1
- import { existsSync } from "node:fs";
2
- import { stat, readFile } from "node:fs/promises";
3
- import { resolve, join, extname, normalize } from "node:path";
4
- import type { IncomingMessage, ServerResponse } from "node:http";
5
-
6
- // Serves the built web-dashboard SPA from the gateway, so a single deployment
7
- // (and single origin) hosts both the API/WebSocket and the UI. The web is
8
- // optional: if WEB_DIST doesn't exist (e.g. local dev using the Vite dev
9
- // server), the gateway runs API-only and this is a no-op.
10
-
11
- const WEB_DIST = process.env.WEB_DIST ?? resolve(process.cwd(), "web");
12
- const hasWeb = existsSync(join(WEB_DIST, "index.html"));
13
-
14
- const CONTENT_TYPES: Record<string, string> = {
15
- ".html": "text/html; charset=utf-8",
16
- ".js": "text/javascript; charset=utf-8",
17
- ".mjs": "text/javascript; charset=utf-8",
18
- ".css": "text/css; charset=utf-8",
19
- ".json": "application/json; charset=utf-8",
20
- ".svg": "image/svg+xml",
21
- ".png": "image/png",
22
- ".jpg": "image/jpeg",
23
- ".jpeg": "image/jpeg",
24
- ".gif": "image/gif",
25
- ".ico": "image/x-icon",
26
- ".webp": "image/webp",
27
- ".woff": "font/woff",
28
- ".woff2": "font/woff2",
29
- ".ttf": "font/ttf",
30
- ".map": "application/json; charset=utf-8",
31
- ".txt": "text/plain; charset=utf-8",
32
- };
33
-
34
- export function webEnabled(): boolean {
35
- return hasWeb;
36
- }
37
-
38
- export function webDistPath(): string {
39
- return WEB_DIST;
40
- }
41
-
42
- async function resolveWebFile(pathname: string, allowSpaFallback: boolean): Promise<string | null> {
43
- // Path-traversal guard: normalize, then confine to WEB_DIST.
44
- const candidate = resolve(WEB_DIST, "." + normalize(pathname));
45
- const inRoot = candidate === WEB_DIST || candidate.startsWith(WEB_DIST + "/");
46
-
47
- let filePath: string | null = null;
48
- if (inRoot && pathname !== "/") {
49
- try {
50
- const s = await stat(candidate);
51
- if (s.isFile()) filePath = candidate;
52
- } catch {
53
- // not a real file
54
- }
55
- }
56
-
57
- // SPA fallback: serve index.html for "/" and for any path without a file
58
- // extension (a client-side route). Missing assets (with an extension) 404.
59
- if (!filePath) {
60
- if (allowSpaFallback && (pathname === "/" || extname(pathname) === "")) {
61
- filePath = join(WEB_DIST, "index.html");
62
- } else {
63
- return null; // let the caller 404/proxy the missing asset
64
- }
65
- }
66
-
67
- return filePath;
68
- }
69
-
70
- async function serveWebFile(req: IncomingMessage, res: ServerResponse, filePath: string): Promise<boolean> {
71
- try {
72
- const ext = extname(filePath).toLowerCase();
73
- const type = CONTENT_TYPES[ext] ?? "application/octet-stream";
74
- res.setHeader("Content-Type", type);
75
- // Hashed assets (Vite emits content-hashed filenames) can cache hard;
76
- // index.html must never be cached so deploys take effect immediately.
77
- if (filePath.endsWith("index.html")) {
78
- res.setHeader("Cache-Control", "no-cache");
79
- // lsh_tunnel is an HttpOnly helper cookie for preview subresources. Older
80
- // builds used Path=/, so stale values are still sent to the LinkShell app
81
- // shell and even /ws handshakes. Clear it whenever the real app document
82
- // is served; /tunnel/... responses will set a fresh value when preview is
83
- // actually opened.
84
- res.setHeader("Set-Cookie", "lsh_tunnel=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax");
85
- } else {
86
- res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
87
- }
88
- if (req.method === "HEAD") {
89
- res.writeHead(200);
90
- res.end();
91
- return true;
92
- }
93
- const body = await readFile(filePath);
94
- res.writeHead(200);
95
- res.end(body);
96
- return true;
97
- } catch {
98
- return false;
99
- }
100
- }
101
-
102
- /**
103
- * Try to serve a concrete web asset without the SPA fallback. This lets the
104
- * gateway protect its own built assets before tunnel cookie fallback handles a
105
- * previewed app's absolute assets such as "/assets/app.css".
106
- */
107
- export async function serveWebAsset(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
108
- if (!hasWeb) return false;
109
- if (req.method !== "GET" && req.method !== "HEAD") return false;
110
-
111
- const url = new URL(req.url ?? "/", "http://localhost");
112
- const pathname = decodeURIComponent(url.pathname);
113
- const filePath = await resolveWebFile(pathname, false);
114
- if (!filePath) return false;
115
- return serveWebFile(req, res, filePath);
116
- }
117
-
118
- /**
119
- * Try to serve a GET/HEAD request from the web dist. Returns true if handled.
120
- * Place AFTER all API routes so it never shadows them. Unmatched non-asset
121
- * paths fall back to index.html (SPA client-side routing).
122
- */
123
- export async function serveWeb(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
124
- if (!hasWeb) return false;
125
- if (req.method !== "GET" && req.method !== "HEAD") return false;
126
-
127
- const url = new URL(req.url ?? "/", "http://localhost");
128
- const pathname = decodeURIComponent(url.pathname);
129
- const filePath = await resolveWebFile(pathname, true);
130
- if (!filePath) return false;
131
-
132
- return serveWebFile(req, res, filePath);
133
- }
package/src/tokens.ts DELETED
@@ -1,159 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import type { GatewayStateStore } from "./state-store.js";
3
-
4
- const CLEANUP_INTERVAL = 5 * 60_000;
5
- const SESSION_TTL = 7 * 24 * 60 * 60_000; // 7 days — prune stale bindings
6
-
7
- interface TokenRecord {
8
- token: string;
9
- sessionIds: Set<string>;
10
- createdAt: number;
11
- lastUsedAt: number;
12
- }
13
-
14
- export class TokenManager {
15
- private tokens = new Map<string, TokenRecord>();
16
- private sessionToToken = new Map<string, string>();
17
- private cleanupTimer: ReturnType<typeof setInterval>;
18
-
19
- constructor(private readonly store?: GatewayStateStore) {
20
- this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL);
21
- }
22
-
23
- async hydrate(): Promise<void> {
24
- if (!this.store) return;
25
- try {
26
- const records = await this.store.loadTokens();
27
- const now = Date.now();
28
- for (const record of records) {
29
- if (now - record.lastUsedAt > SESSION_TTL) {
30
- void this.store.deleteToken(record.token).catch(() => {});
31
- continue;
32
- }
33
- this.tokens.set(record.token, {
34
- token: record.token,
35
- sessionIds: new Set(record.sessionIds),
36
- createdAt: record.createdAt,
37
- lastUsedAt: record.lastUsedAt,
38
- });
39
- for (const sessionId of record.sessionIds) {
40
- this.sessionToToken.set(sessionId, record.token);
41
- }
42
- }
43
- } catch (err) {
44
- process.stderr.write(`[gateway] token store hydrate failed, using memory only: ${err}\n`);
45
- }
46
- }
47
-
48
- register(deviceToken?: string): string {
49
- if (deviceToken && this.tokens.has(deviceToken)) {
50
- const record = this.tokens.get(deviceToken)!;
51
- record.lastUsedAt = Date.now();
52
- this.persist(record);
53
- return deviceToken;
54
- }
55
- const token = deviceToken || randomUUID();
56
- this.tokens.set(token, {
57
- token,
58
- sessionIds: new Set(),
59
- createdAt: Date.now(),
60
- lastUsedAt: Date.now(),
61
- });
62
- this.persist(this.tokens.get(token)!);
63
- return token;
64
- }
65
-
66
- bind(token: string, sessionId: string): boolean {
67
- const record = this.tokens.get(token);
68
- if (!record) return false;
69
- record.sessionIds.add(sessionId);
70
- record.lastUsedAt = Date.now();
71
- this.sessionToToken.set(sessionId, token);
72
- this.persist(record);
73
- return true;
74
- }
75
-
76
- validate(token: string): boolean {
77
- const record = this.tokens.get(token);
78
- if (!record) return false;
79
- record.lastUsedAt = Date.now();
80
- this.persist(record);
81
- return true;
82
- }
83
-
84
- owns(token: string, sessionId: string): boolean {
85
- const record = this.tokens.get(token);
86
- if (!record) return false;
87
- record.lastUsedAt = Date.now();
88
- this.persist(record);
89
- return record.sessionIds.has(sessionId);
90
- }
91
-
92
- async ownsFresh(token: string, sessionId: string): Promise<boolean> {
93
- if (this.owns(token, sessionId)) return true;
94
- if (!this.store) return false;
95
- try {
96
- const records = await this.store.loadTokens();
97
- const now = Date.now();
98
- for (const record of records) {
99
- if (now - record.lastUsedAt > SESSION_TTL) continue;
100
- if (record.token !== token) continue;
101
- const next = {
102
- token: record.token,
103
- sessionIds: new Set(record.sessionIds),
104
- createdAt: record.createdAt,
105
- lastUsedAt: Date.now(),
106
- };
107
- this.tokens.set(record.token, next);
108
- for (const sid of record.sessionIds) {
109
- this.sessionToToken.set(sid, record.token);
110
- }
111
- this.persist(next);
112
- return next.sessionIds.has(sessionId);
113
- }
114
- } catch (err) {
115
- process.stderr.write(`[gateway] token store refresh failed: ${err}\n`);
116
- }
117
- return false;
118
- }
119
-
120
- getSessionIds(token: string): Set<string> {
121
- const record = this.tokens.get(token);
122
- if (!record) return new Set();
123
- record.lastUsedAt = Date.now();
124
- this.persist(record);
125
- return record.sessionIds;
126
- }
127
-
128
- getTokenForSession(sessionId: string): string | undefined {
129
- return this.sessionToToken.get(sessionId);
130
- }
131
-
132
- private cleanup(): void {
133
- const now = Date.now();
134
- for (const [token, record] of this.tokens) {
135
- if (now - record.lastUsedAt > SESSION_TTL) {
136
- for (const sid of record.sessionIds) {
137
- this.sessionToToken.delete(sid);
138
- }
139
- this.tokens.delete(token);
140
- void this.store?.deleteToken(token).catch(() => {});
141
- }
142
- }
143
- }
144
-
145
- private persist(record: TokenRecord): void {
146
- void this.store?.saveToken({
147
- token: record.token,
148
- sessionIds: [...record.sessionIds],
149
- createdAt: record.createdAt,
150
- lastUsedAt: record.lastUsedAt,
151
- }).catch((err) => {
152
- process.stderr.write(`[gateway] token store save failed: ${err}\n`);
153
- });
154
- }
155
-
156
- destroy(): void {
157
- clearInterval(this.cleanupTimer);
158
- }
159
- }