@linkshell/gateway 0.2.26 → 0.2.27

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,131 @@
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
+
27
+ function msToIso(ms: number): string {
28
+ return new Date(ms).toISOString();
29
+ }
30
+
31
+ function isoToMs(value: unknown): number {
32
+ if (typeof value !== "string") return Date.now();
33
+ const parsed = Date.parse(value);
34
+ return Number.isNaN(parsed) ? Date.now() : parsed;
35
+ }
36
+
37
+ export function createSupabaseStateStore(): GatewayStateStore | undefined {
38
+ const url = process.env.SUPABASE_URL?.replace(/\/+$/, "");
39
+ const key = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_ANON_KEY;
40
+ if (!url || !key) return undefined;
41
+
42
+ const headers = {
43
+ apikey: key,
44
+ Authorization: `Bearer ${key}`,
45
+ "Content-Type": "application/json",
46
+ };
47
+
48
+ async function request<T>(path: string, init?: RequestInit): Promise<T> {
49
+ const res = await fetch(`${url}/rest/v1/${path}`, {
50
+ ...init,
51
+ headers: {
52
+ ...headers,
53
+ ...(init?.headers ?? {}),
54
+ },
55
+ });
56
+ if (!res.ok) {
57
+ const body = await res.text().catch(() => "");
58
+ throw new Error(`Supabase state store ${res.status}: ${body || res.statusText}`);
59
+ }
60
+ if (res.status === 204) return undefined as T;
61
+ return (await res.json()) as T;
62
+ }
63
+
64
+ return {
65
+ async loadTokens() {
66
+ const rows = await request<Array<Record<string, unknown>>>(
67
+ `${TOKEN_TABLE}?select=token,session_ids,created_at,last_used_at`,
68
+ );
69
+ return rows.map((row) => ({
70
+ token: String(row.token ?? ""),
71
+ sessionIds: Array.isArray(row.session_ids)
72
+ ? row.session_ids.map(String)
73
+ : [],
74
+ createdAt: isoToMs(row.created_at),
75
+ lastUsedAt: isoToMs(row.last_used_at),
76
+ })).filter((record) => record.token);
77
+ },
78
+ async saveToken(record) {
79
+ await request(
80
+ `${TOKEN_TABLE}?on_conflict=token`,
81
+ {
82
+ method: "POST",
83
+ headers: { Prefer: "resolution=merge-duplicates" },
84
+ body: JSON.stringify({
85
+ token: record.token,
86
+ session_ids: record.sessionIds,
87
+ created_at: msToIso(record.createdAt),
88
+ last_used_at: msToIso(record.lastUsedAt),
89
+ }),
90
+ },
91
+ );
92
+ },
93
+ async deleteToken(token) {
94
+ await request(`${TOKEN_TABLE}?token=eq.${encodeURIComponent(token)}`, {
95
+ method: "DELETE",
96
+ });
97
+ },
98
+ async loadPairings() {
99
+ const rows = await request<Array<Record<string, unknown>>>(
100
+ `${PAIRING_TABLE}?select=pairing_code,session_id,expires_at,claimed`,
101
+ );
102
+ return rows.map((row) => ({
103
+ pairingCode: String(row.pairing_code ?? ""),
104
+ sessionId: String(row.session_id ?? ""),
105
+ expiresAt: isoToMs(row.expires_at),
106
+ claimed: row.claimed === true,
107
+ })).filter((record) => record.pairingCode && record.sessionId);
108
+ },
109
+ async savePairing(record) {
110
+ await request(
111
+ `${PAIRING_TABLE}?on_conflict=pairing_code`,
112
+ {
113
+ method: "POST",
114
+ headers: { Prefer: "resolution=merge-duplicates" },
115
+ body: JSON.stringify({
116
+ pairing_code: record.pairingCode,
117
+ session_id: record.sessionId,
118
+ expires_at: msToIso(record.expiresAt),
119
+ claimed: record.claimed,
120
+ }),
121
+ },
122
+ );
123
+ },
124
+ async deletePairing(pairingCode) {
125
+ await request(
126
+ `${PAIRING_TABLE}?pairing_code=eq.${encodeURIComponent(pairingCode)}`,
127
+ { method: "DELETE" },
128
+ );
129
+ },
130
+ };
131
+ }
package/src/tokens.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import type { GatewayStateStore } from "./state-store.js";
2
3
 
3
4
  const CLEANUP_INTERVAL = 5 * 60_000;
4
5
  const SESSION_TTL = 7 * 24 * 60 * 60_000; // 7 days — prune stale bindings
@@ -15,14 +16,40 @@ export class TokenManager {
15
16
  private sessionToToken = new Map<string, string>();
16
17
  private cleanupTimer: ReturnType<typeof setInterval>;
17
18
 
18
- constructor() {
19
+ constructor(private readonly store?: GatewayStateStore) {
19
20
  this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL);
20
21
  }
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
+
22
48
  register(deviceToken?: string): string {
23
49
  if (deviceToken && this.tokens.has(deviceToken)) {
24
50
  const record = this.tokens.get(deviceToken)!;
25
51
  record.lastUsedAt = Date.now();
52
+ this.persist(record);
26
53
  return deviceToken;
27
54
  }
28
55
  const token = deviceToken || randomUUID();
@@ -32,6 +59,7 @@ export class TokenManager {
32
59
  createdAt: Date.now(),
33
60
  lastUsedAt: Date.now(),
34
61
  });
62
+ this.persist(this.tokens.get(token)!);
35
63
  return token;
36
64
  }
37
65
 
@@ -41,6 +69,7 @@ export class TokenManager {
41
69
  record.sessionIds.add(sessionId);
42
70
  record.lastUsedAt = Date.now();
43
71
  this.sessionToToken.set(sessionId, token);
72
+ this.persist(record);
44
73
  return true;
45
74
  }
46
75
 
@@ -48,6 +77,7 @@ export class TokenManager {
48
77
  const record = this.tokens.get(token);
49
78
  if (!record) return false;
50
79
  record.lastUsedAt = Date.now();
80
+ this.persist(record);
51
81
  return true;
52
82
  }
53
83
 
@@ -55,6 +85,7 @@ export class TokenManager {
55
85
  const record = this.tokens.get(token);
56
86
  if (!record) return false;
57
87
  record.lastUsedAt = Date.now();
88
+ this.persist(record);
58
89
  return record.sessionIds.has(sessionId);
59
90
  }
60
91
 
@@ -62,6 +93,7 @@ export class TokenManager {
62
93
  const record = this.tokens.get(token);
63
94
  if (!record) return new Set();
64
95
  record.lastUsedAt = Date.now();
96
+ this.persist(record);
65
97
  return record.sessionIds;
66
98
  }
67
99
 
@@ -77,10 +109,22 @@ export class TokenManager {
77
109
  this.sessionToToken.delete(sid);
78
110
  }
79
111
  this.tokens.delete(token);
112
+ void this.store?.deleteToken(token).catch(() => {});
80
113
  }
81
114
  }
82
115
  }
83
116
 
117
+ private persist(record: TokenRecord): void {
118
+ void this.store?.saveToken({
119
+ token: record.token,
120
+ sessionIds: [...record.sessionIds],
121
+ createdAt: record.createdAt,
122
+ lastUsedAt: record.lastUsedAt,
123
+ }).catch((err) => {
124
+ process.stderr.write(`[gateway] token store save failed: ${err}\n`);
125
+ });
126
+ }
127
+
84
128
  destroy(): void {
85
129
  clearInterval(this.cleanupTimer);
86
130
  }