cursedbelt-server 4.13.1 → 4.15.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.
@@ -16,7 +16,7 @@
16
16
  */
17
17
  export { type BackupPoint, createTimeTravelBackup, type DatabaseBackup, type TimeTravelOpts, } from './backup.js';
18
18
  export { createPendingWrites, type InvocationD1, type PendingWrites, perInvocation, type WriteCollector } from './invocation.js';
19
- export { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits.js';
19
+ export { assertBatchSize, assertWithinLimits, chunkForBind, inArray, LIMITS } from './limits.js';
20
20
  export { createLocalD1, refuseInteractiveTransaction } from './local.js';
21
21
  export { createRemoteD1, type D1BindingLike, type D1BindingMeta, type D1BindingResult, type D1BindingStatement, } from './remote.js';
22
22
  export { classifySchedule, cronFieldCount, jobsForCron, type PortableJob, type PortableJobContext, type ScheduleVerdict, toCronTriggers, toFiveField, type TriggerShape, type WorkersPrimitive, } from './scheduling.js';
@@ -34,7 +34,7 @@ export { createTimeTravelBackup, } from './backup.js';
34
34
  // of them, since business tables stay on raw statements. Measured 2026-09-18 against the
35
35
  // published 4.3.0 tarball. `barrelsReachNoOptionalPeer.spec.ts` is what keeps it out.
36
36
  export { createPendingWrites, perInvocation } from './invocation.js';
37
- export { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits.js';
37
+ export { assertBatchSize, assertWithinLimits, chunkForBind, inArray, LIMITS } from './limits.js';
38
38
  export { createLocalD1, refuseInteractiveTransaction } from './local.js';
39
39
  export { createRemoteD1, } from './remote.js';
40
40
  export { classifySchedule, cronFieldCount, jobsForCron, toCronTriggers, toFiveField, } from './scheduling.js';
@@ -54,3 +54,34 @@ export declare function assertBatchSize(count: number): void;
54
54
  * ```
55
55
  */
56
56
  export declare function chunkForBind<T>(items: readonly T[], paramsPerItem: number): T[][];
57
+ /**
58
+ * `column IN (…)` over a list of ANY length, as ONE bound parameter.
59
+ *
60
+ * ```ts
61
+ * const ids = inArray(itemIds);
62
+ * await db.prepare(`UPDATE items SET deleted_at = ? WHERE id ${ids.sql}`).bind(now, ids.param).run();
63
+ * ```
64
+ *
65
+ * 🔴 **Why this and not {@link chunkForBind} for a set of ids.** An `IN (?, ?, …)` built by
66
+ * `ids.map(() => '?')` binds one parameter per id, so it is green on every fixture and a 500 on
67
+ * the 101st id — the cap is asserted on both drivers, and real D1 enforces the same one.
68
+ * Chunking fixes a bulk INSERT, but it cannot fix a READ that must be one statement (a
69
+ * `WHERE … ORDER BY … LIMIT ? OFFSET ?` page and the `COUNT(*)` beside it), and on a write
70
+ * it trades one atomic statement for a batch that is atomic only per chunk. `json_each`
71
+ * over a JSON array is one parameter however long the list is, keeps the statement whole,
72
+ * and SQLite still drives the column's index through the materialised set.
73
+ *
74
+ * The array travels as one TEXT value, so the ceiling moves from 100 ids to
75
+ * `LIMITS.valueBytes` (2 MB) of JSON — about 70,000 of this fleet's 25-character ids — and
76
+ * past that `assertWithinLimits` refuses it at `bind()` with the byte count, on both drivers.
77
+ * An empty list is valid SQL and matches nothing, so no caller needs an `IN ()` special case.
78
+ *
79
+ * Only strings and finite numbers: `json_each` yields TEXT for a string and INTEGER/REAL for
80
+ * a number, which is what makes `id IN …` compare like `id = ?` would. Anything else would
81
+ * compare as something the caller did not write, so it is refused here rather than matched
82
+ * silently against nothing.
83
+ */
84
+ export declare function inArray(values: readonly (string | number)[]): {
85
+ sql: string;
86
+ param: string;
87
+ };
@@ -94,3 +94,40 @@ export function chunkForBind(items, paramsPerItem) {
94
94
  out.push(items.slice(i, i + size));
95
95
  return out;
96
96
  }
97
+ /**
98
+ * `column IN (…)` over a list of ANY length, as ONE bound parameter.
99
+ *
100
+ * ```ts
101
+ * const ids = inArray(itemIds);
102
+ * await db.prepare(`UPDATE items SET deleted_at = ? WHERE id ${ids.sql}`).bind(now, ids.param).run();
103
+ * ```
104
+ *
105
+ * 🔴 **Why this and not {@link chunkForBind} for a set of ids.** An `IN (?, ?, …)` built by
106
+ * `ids.map(() => '?')` binds one parameter per id, so it is green on every fixture and a 500 on
107
+ * the 101st id — the cap is asserted on both drivers, and real D1 enforces the same one.
108
+ * Chunking fixes a bulk INSERT, but it cannot fix a READ that must be one statement (a
109
+ * `WHERE … ORDER BY … LIMIT ? OFFSET ?` page and the `COUNT(*)` beside it), and on a write
110
+ * it trades one atomic statement for a batch that is atomic only per chunk. `json_each`
111
+ * over a JSON array is one parameter however long the list is, keeps the statement whole,
112
+ * and SQLite still drives the column's index through the materialised set.
113
+ *
114
+ * The array travels as one TEXT value, so the ceiling moves from 100 ids to
115
+ * `LIMITS.valueBytes` (2 MB) of JSON — about 70,000 of this fleet's 25-character ids — and
116
+ * past that `assertWithinLimits` refuses it at `bind()` with the byte count, on both drivers.
117
+ * An empty list is valid SQL and matches nothing, so no caller needs an `IN ()` special case.
118
+ *
119
+ * Only strings and finite numbers: `json_each` yields TEXT for a string and INTEGER/REAL for
120
+ * a number, which is what makes `id IN …` compare like `id = ?` would. Anything else would
121
+ * compare as something the caller did not write, so it is refused here rather than matched
122
+ * silently against nothing.
123
+ */
124
+ export function inArray(values) {
125
+ for (const v of values) {
126
+ if (typeof v === 'string')
127
+ continue;
128
+ if (typeof v === 'number' && Number.isFinite(v))
129
+ continue;
130
+ throw new TypeError(`inArray takes strings and finite numbers, got ${typeof v}: ${String(v)}`);
131
+ }
132
+ return { sql: 'IN (SELECT value FROM json_each(?))', param: JSON.stringify(values) };
133
+ }
@@ -0,0 +1,303 @@
1
+ export declare const GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
2
+ export declare const GOOGLE_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
3
+ export declare const GOOGLE_REVOKE_ENDPOINT = "https://oauth2.googleapis.com/revoke";
4
+ /**
5
+ * Deliberately looser than `typeof fetch`.
6
+ *
7
+ * Every caller here passes one URL string and an init, and `typeof fetch` also carries Bun's
8
+ * `preconnect` property — so typing the seam as `typeof fetch` makes `async () =>
9
+ * Response.json(…)` a compile error in every test that fakes it, for a capability no code
10
+ * path uses.
11
+ */
12
+ export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
13
+ /**
14
+ * An environment to read credentials from. `process.env` satisfies it, and so does a plain
15
+ * object in a test — deliberately not `NodeJS.ProcessEnv`, so a consumer's declarations do not
16
+ * need `@types/node` to type-check against this one.
17
+ */
18
+ export type EnvLike = Readonly<Record<string, string | undefined>>;
19
+ export interface GoogleCredentials {
20
+ clientId: string;
21
+ clientSecret: string;
22
+ refreshToken: string;
23
+ }
24
+ /**
25
+ * WHICH KIND of `unavailable`, structurally rather than by reading the sentence.
26
+ *
27
+ * 🔴 A caller that treats these two alike rings the wrong alarm. `refused` is Google saying no
28
+ * to a credential the owner has to act on; `transport` is a machine that could not reach
29
+ * Google — a laptop on a train — and a bell claiming a dead grant every time the wifi drops is
30
+ * how a real credential alarm gets ignored.
31
+ */
32
+ export type UnavailableCause = 'transport' | 'refused';
33
+ /** Everything below `ok` carries the exact remedy — never a quiet empty result. */
34
+ export type TokenResult = {
35
+ status: 'ok';
36
+ accessToken: string;
37
+ grantedScopes: string[];
38
+ } | {
39
+ status: 'unconfigured';
40
+ reason: string;
41
+ fix: string;
42
+ } | {
43
+ status: 'unavailable';
44
+ reason: string;
45
+ fix: string;
46
+ cause?: UnavailableCause;
47
+ };
48
+ /** `~/x` → `<home>/x`. Anything else is returned unchanged. */
49
+ export declare const expandTilde: (p: string) => string;
50
+ /**
51
+ * Parse a `KEY=value` env file, tolerating quotes, blanks, `export`, `#` comments and CRLF.
52
+ *
53
+ * 🔴 CRLF is split on, not trimmed afterwards. Both app copies split on `\n` alone, and `.`
54
+ * does not match `\r`, so `(.*)$` failed on EVERY line of a CRLF file: a secrets file saved by
55
+ * a Windows editor parsed to `{}` and read as "no credential on this machine" — `unconfigured`,
56
+ * with the setup remedy, for a file that held all three values. Pinned in the spec.
57
+ */
58
+ export declare function parseEnvFile(text: string): Record<string, string>;
59
+ /** Read any other key — env first, then the same secrets file (an API key, an address). */
60
+ export declare function readSecret(name: string, options?: {
61
+ env?: EnvLike;
62
+ secretsFile?: string;
63
+ }): string;
64
+ /**
65
+ * The env-var names a caller will accept for each half of the credential, most canonical
66
+ * FIRST. A consumer with historical aliases lists them after its canonical names; a new
67
+ * consumer passes one name each.
68
+ */
69
+ export interface CredentialKeys {
70
+ clientId: readonly string[];
71
+ clientSecret: readonly string[];
72
+ refreshToken: readonly string[];
73
+ }
74
+ export declare const CANONICAL_CREDENTIAL_KEYS: CredentialKeys;
75
+ export interface ReadCredentialsOptions {
76
+ env?: EnvLike;
77
+ /** A 0600 file outside every checkout. `~` is expanded. The caller decides where it is. */
78
+ secretsFile?: string;
79
+ keys?: CredentialKeys;
80
+ }
81
+ /** Which name supplied one half of a credential, and where that name was read from. */
82
+ export interface CredentialSource {
83
+ name: string;
84
+ from: 'env' | 'file';
85
+ }
86
+ /**
87
+ * WHICH NAMES a credential actually came out of.
88
+ *
89
+ * 🔴 Why this is not a debugging nicety. A credential list is a PREFERENCE order, so a value
90
+ * under the preferred name wins merely by existing — it is never checked against the value
91
+ * under the alias. Measured 2026-09-15: one 0600 file held two different refresh tokens for
92
+ * the identical client and account, the canonical name was dead (`invalid_grant`) and the
93
+ * alias beside it exchanged fine. Every call took the dead one and reported "the grant has
94
+ * been revoked, re-consent" — a sentence that was false, sent the owner to a browser, and
95
+ * cost forty minutes of not believing it. `shadowed` makes that a five-second diagnosis.
96
+ */
97
+ export interface GoogleCredentialResolution {
98
+ /** The same value `readGoogleCredentials` returns — null if any half is missing. */
99
+ credentials: GoogleCredentials | null;
100
+ /** The name that supplied each half, or null when no listed name had a value. */
101
+ used: {
102
+ clientId: CredentialSource | null;
103
+ clientSecret: CredentialSource | null;
104
+ refreshToken: CredentialSource | null;
105
+ };
106
+ /** Names holding a value that lost to an earlier one. Never a value, only a name. */
107
+ shadowed: {
108
+ clientId: string[];
109
+ clientSecret: string[];
110
+ refreshToken: string[];
111
+ };
112
+ /** Every listed name for a half that nothing supplied — the `unconfigured` detail. */
113
+ missing: string[];
114
+ }
115
+ /**
116
+ * Read a Google OAuth credential AND report which names carried it.
117
+ *
118
+ * Precedence: env overrides the secrets file, and earlier key names override later aliases.
119
+ */
120
+ export declare function resolveGoogleCredentials(options?: ReadCredentialsOptions): GoogleCredentialResolution;
121
+ /**
122
+ * Read a Google OAuth credential — env overriding the secrets file, and earlier key names
123
+ * overriding later aliases.
124
+ *
125
+ * Returns null when any of the three is missing. A PARTIAL credential is the same as none:
126
+ * two of three cannot mint a token, and treating it as present would turn a setup mistake
127
+ * into an `unavailable` ("Google refused it") instead of the `unconfigured` ("you have not
128
+ * finished setting it up") that names the real fix.
129
+ */
130
+ export declare function readGoogleCredentials(options?: ReadCredentialsOptions): GoogleCredentials | null;
131
+ export interface ConsentUrlOptions {
132
+ clientId: string;
133
+ /**
134
+ * Must match an Authorized redirect URI on the OAuth client EXACTLY — Google compares the
135
+ * string, not the URL. A trailing slash is a different URI.
136
+ */
137
+ redirectUri: string;
138
+ scopes: readonly string[];
139
+ /** The caller's CSRF/binding token. Google hands it back on the callback verbatim. */
140
+ state: string;
141
+ /**
142
+ * `consent` (the default) forces Google to re-issue a refresh token.
143
+ *
144
+ * 🔴 Not a preference. Google issues `refresh_token` only on the FIRST consent for a
145
+ * client+account pair; a second connect without this returns an access token and nothing
146
+ * else, so an app that stored only what arrived would be connected for an hour and then
147
+ * permanently unable to refresh.
148
+ */
149
+ prompt?: 'consent' | 'select_account' | 'none';
150
+ }
151
+ /** The URL to send the browser to. Pure — no network, so a caller can test its own wiring. */
152
+ export declare function buildConsentUrl(options: ConsentUrlOptions): string;
153
+ export interface ExchangeOptions {
154
+ clientId: string;
155
+ clientSecret: string;
156
+ redirectUri: string;
157
+ code: string;
158
+ fetchImpl?: FetchLike;
159
+ timeoutMs?: number;
160
+ }
161
+ export type AuthCodeResult = {
162
+ status: 'ok';
163
+ accessToken: string;
164
+ /**
165
+ * Absent when Google chose not to re-issue one. A caller that already holds a refresh
166
+ * token for this account must KEEP it rather than storing an empty string — see
167
+ * {@link ConsentUrlOptions.prompt}.
168
+ */
169
+ refreshToken?: string;
170
+ expiresInSeconds: number;
171
+ grantedScopes: string[];
172
+ } | {
173
+ status: 'unavailable';
174
+ reason: string;
175
+ fix: string;
176
+ /** See {@link UnavailableCause}. Always set by this module; optional in the type. */
177
+ cause?: UnavailableCause;
178
+ };
179
+ /** Trade the `?code=` Google sent to the callback for tokens. */
180
+ export declare function exchangeAuthCode(options: ExchangeOptions): Promise<AuthCodeResult>;
181
+ export interface RefreshOptions {
182
+ clientId: string;
183
+ clientSecret: string;
184
+ refreshToken: string;
185
+ fetchImpl?: FetchLike;
186
+ timeoutMs?: number;
187
+ /** What to tell the owner when Google refuses the token — usually "re-consent". */
188
+ refusalFix?: string;
189
+ }
190
+ /**
191
+ * Trade a stored refresh token for a fresh access token, REPORTING ITS LIFETIME.
192
+ *
193
+ * The uncached half of {@link createGoogleTokenMinter}, exported because a consumer that
194
+ * PERSISTS the access token has to know when it dies — a caller that guesses an hour is a
195
+ * caller whose token is occasionally already dead when it is used.
196
+ */
197
+ export declare function refreshAccessToken(options: RefreshOptions): Promise<AuthCodeResult>;
198
+ /**
199
+ * Best-effort revocation on disconnect. NEVER throws and never reports failure as a blocker:
200
+ * the local record is deleted either way, and a disconnect that refuses because Google was
201
+ * unreachable leaves the owner holding a connection they asked to end. Google having already
202
+ * forgotten the token answers 400, which is the same outcome as success from here — `false`
203
+ * says only "Google did not confirm it".
204
+ */
205
+ export declare function revokeGoogleToken(token: string, options?: {
206
+ fetchImpl?: FetchLike;
207
+ timeoutMs?: number;
208
+ }): Promise<boolean>;
209
+ export interface TokenMinterOptions {
210
+ /**
211
+ * Supplied directly (a consenting app's stored refresh token). `null` means "there are
212
+ * none"; `undefined` means "read them from `env` + `secretsFile` under `keys`".
213
+ */
214
+ credentials?: GoogleCredentials | null;
215
+ env?: EnvLike;
216
+ secretsFile?: string;
217
+ keys?: CredentialKeys;
218
+ fetchImpl?: FetchLike;
219
+ timeoutMs?: number;
220
+ now?: () => number;
221
+ /**
222
+ * What to tell the owner when the credential is missing or Google refuses it. Each consumer
223
+ * mints its token a different way, so the remedy is theirs.
224
+ */
225
+ setupFix?: string;
226
+ /** Named in the failure sentences, e.g. "the digest" or "the YouTube connection". */
227
+ label?: string;
228
+ }
229
+ export interface GoogleTokenMinter {
230
+ /** A valid access token, or a typed failure carrying the remedy. */
231
+ token(): Promise<TokenResult>;
232
+ /**
233
+ * The scopes Google says this grant actually has. Empty when the token could not be minted
234
+ * at all — callers must distinguish "no scopes" from "could not ask".
235
+ */
236
+ grantedScopes(): Promise<string[]>;
237
+ /** Drop the cached token — for a test, or after a 401 that suggests a stale one. */
238
+ invalidate(): void;
239
+ }
240
+ /**
241
+ * A short-lived access token, cached in memory until {@link EXPIRY_MARGIN_MS} before it
242
+ * expires.
243
+ *
244
+ * The refresh token is exchanged on demand rather than held as a long-lived bearer, and the
245
+ * cache exists because one caller makes several calls across several APIs — minting per call
246
+ * would make the caller's latency proportional to its call count.
247
+ */
248
+ export declare function createGoogleTokenMinter(options?: TokenMinterOptions): GoogleTokenMinter;
249
+ /**
250
+ * What an EXCHANGE of the credential a caller is about to use actually did.
251
+ *
252
+ * Deliberately not a `TokenResult`: no access token comes out of here, because a probe that
253
+ * handed one back would become a second minting path with its own cache.
254
+ */
255
+ export interface CredentialProbe {
256
+ /**
257
+ * FOUR states, and the split between the last two is the load-bearing one: `refused` is
258
+ * Google saying no to this credential and is the owner's to fix; `unreachable` is this
259
+ * machine's network and fixes itself.
260
+ */
261
+ status: 'ok' | 'unconfigured' | 'refused' | 'unreachable';
262
+ /** The env name whose refresh token was exchanged. "" when none was found or credentials were supplied. */
263
+ refreshTokenName: string;
264
+ /** Other names for the refresh token that hold a value and were NOT used. */
265
+ shadowedRefreshTokenNames: string[];
266
+ /** What Google says this grant carries. Empty unless `status` is "ok". */
267
+ grantedScopes: string[];
268
+ /** One sentence for a bell, a boot log or a health body. Never a credential value. */
269
+ summary: string;
270
+ /** The exact next action. "" when `status` is "ok". */
271
+ fix: string;
272
+ }
273
+ /**
274
+ * 🔴 EXCHANGE the credential rather than assuming a present value is a working one.
275
+ *
276
+ * `readGoogleCredentials` cannot tell a dead value from a live one, because "present" is all
277
+ * it ever asked. The probe does the one thing that answers the question: a real
278
+ * `grant_type=refresh_token` POST, reported with the NAME it used and the names it shadowed.
279
+ * It is safe to run at boot — it mints a short-lived access token, drops it, and never throws.
280
+ */
281
+ export declare function probeCredentialExchange(options?: TokenMinterOptions): Promise<CredentialProbe>;
282
+ /**
283
+ * Turn a Google 403 into something the owner can act on.
284
+ *
285
+ * A missing scope is the single most likely failure after the owner consents once and the app
286
+ * later grows a capability. Naming the MISSING scope and the re-consent command is the
287
+ * difference between a two-minute fix and an evening.
288
+ *
289
+ * 🔴 The two branches say genuinely different things: `ACCESS_TOKEN_SCOPE_INSUFFICIENT` is a
290
+ * consent problem fixed in a browser, while a 403 on a granted scope is an API that is not
291
+ * ENABLED on the project, fixed in a different console page entirely.
292
+ */
293
+ export declare function describeScopeFailure(options: {
294
+ api: string;
295
+ needed: string;
296
+ granted: readonly string[];
297
+ httpStatus: number;
298
+ /** The command that re-runs consent, e.g. `bun run google:consent`. */
299
+ consentCommand: string;
300
+ }): {
301
+ reason: string;
302
+ fix: string;
303
+ };