@shipstatic/types 2.7.0 → 2.8.0-beta.2

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.
package/dist/index.d.ts CHANGED
@@ -1118,6 +1118,39 @@ export declare const DEPLOY_TOKEN: {
1118
1118
  /** Total length of a deploy token including prefix (`PREFIX.length + HEX_LENGTH = 39`). */
1119
1119
  readonly TOTAL_LENGTH: 39;
1120
1120
  };
1121
+ /**
1122
+ * Shape constants for OAuth access tokens (`oauth-{32 hex chars}`) — the
1123
+ * delegated population, minted by the platform's own authorization server
1124
+ * for a connected app acting on a user's behalf.
1125
+ *
1126
+ * Same width as the other two, and for the same reason: one entropy standard
1127
+ * across the platform, so "how long is a credential" has one answer.
1128
+ *
1129
+ * **This population is the access token alone.** Refresh tokens, authorization
1130
+ * codes and client secrets are deliberately NOT here and are deliberately not
1131
+ * prefixed by this constant: none of them ever enters the `Authorization:
1132
+ * Bearer` slot — a refresh token is posted as a form field to the token
1133
+ * endpoint, which knows what it is receiving — so `classifyToken` never sees
1134
+ * one and a prefix would name a population no dispatcher dispatches. The same
1135
+ * reasoning that keeps the deployment claim code bare.
1136
+ *
1137
+ * **The prefix must be applied at the MINT, never as a display wrapper.** The
1138
+ * authorization server hashes what it stores and the API hashes what it is
1139
+ * presented, so the prefix has to be inside the hashed string on both sides.
1140
+ * `@better-auth/oauth-provider` offers a `prefix.opaqueAccessToken` option
1141
+ * that prepends AFTER hashing and strips on its own read paths; using it would
1142
+ * store a hash of the UNPREFIXED token and silently break the platform's read
1143
+ * arm. The API therefore mints through `generateOpaqueAccessToken` — recorded
1144
+ * beside the config in `cloudflare/api/src/lib/auth/instance.ts`.
1145
+ */
1146
+ export declare const OAUTH_TOKEN: {
1147
+ /** Prefix that identifies an OAuth access token. */
1148
+ readonly PREFIX: "oauth-";
1149
+ /** Number of hex characters following the prefix. */
1150
+ readonly HEX_LENGTH: 32;
1151
+ /** Total length including prefix (`PREFIX.length + HEX_LENGTH = 38`). */
1152
+ readonly TOTAL_LENGTH: 38;
1153
+ };
1121
1154
  /**
1122
1155
  * Shape constants for caller identifiers (the `X-Caller` instance-identity
1123
1156
  * header — rate-limit bucketing for multi-tenant orchestrators). The API
@@ -1138,16 +1171,23 @@ export declare const CALLER: {
1138
1171
  * client token in one wire slot (`Authorization: Bearer <value>`) and
1139
1172
  * classifies by value, never by a side channel — this is the classifier.
1140
1173
  *
1141
- * `API_KEY` and `DEPLOY_TOKEN` *are* `AuthMethod.API_KEY` and
1142
- * `AuthMethod.TOKEN` — the equality is structural, so a classification flows
1143
- * straight into an auth method and the pair can never drift. `OPAQUE` is any
1144
- * other value — shape says nothing about it, so only a lookup can. Today the
1145
- * server refuses every opaque bearer; the OAuth access-token population
1146
- * resolves there when the authorization server ships.
1174
+ * `API_KEY`, `DEPLOY_TOKEN` and `OAUTH` *are* `AuthMethod.API_KEY`,
1175
+ * `AuthMethod.TOKEN` and `AuthMethod.OAUTH` — the equality is structural, so
1176
+ * a classification flows straight into an auth method and the trio can never
1177
+ * drift.
1178
+ *
1179
+ * `OPAQUE` is any other value, and since 2026-08-14 it names NO population:
1180
+ * every credential this platform mints for the Bearer slot carries a prefix,
1181
+ * so an opaque bearer is a bearer we did not mint. It stays a member rather
1182
+ * than becoming a `null` return because a dispatcher with a total codomain
1183
+ * reads better than one with an absence in it — and because it is where a
1184
+ * future population would land before anyone gave it a shape, which is
1185
+ * exactly what the OAuth token itself did until its prefix existed.
1147
1186
  */
1148
1187
  export declare const TokenKind: {
1149
1188
  readonly API_KEY: "apiKey";
1150
1189
  readonly DEPLOY_TOKEN: "token";
1190
+ readonly OAUTH: "oauth";
1151
1191
  readonly OPAQUE: "opaque";
1152
1192
  };
1153
1193
  export type TokenKindType = (typeof TokenKind)[keyof typeof TokenKind];
@@ -1158,6 +1198,43 @@ export type TokenKindType = (typeof TokenKind)[keyof typeof TokenKind];
1158
1198
  * is what guarantees client and server can never disagree on dispatch.
1159
1199
  */
1160
1200
  export declare function classifyToken(token: string): TokenKindType;
1201
+ /**
1202
+ * Read the credential out of an `Authorization` header value — the step
1203
+ * BEFORE `classifyToken`, and the other half of the one wire slot this
1204
+ * section owns.
1205
+ *
1206
+ * Returns the credential's own bytes, or `null` when the header carries a
1207
+ * foreign scheme or nothing after the scheme.
1208
+ *
1209
+ * **The scheme is folded; the credential is not.** RFC 7235 §2.1 makes the
1210
+ * auth-scheme case-insensitive, so `bearer`, `Bearer` and `BEARER` are the
1211
+ * same header. The value after it is opaque and is compared literally
1212
+ * everywhere it is used — `ship-`/`deploy-`/`oauth-` are lowercase hex, and
1213
+ * folding them would make a credential match values it is not.
1214
+ *
1215
+ * **This platform has paid for the rule twice, which is why it has an owner
1216
+ * rather than a convention.** A spec-conformant `bearer ship-…` client was
1217
+ * refused for as long as the API's scheme test was spelled case-sensitively;
1218
+ * and `@better-auth/oauth-provider` carries the same defect in four places
1219
+ * today (`startsWith("Bearer ")`), which is precisely why the platform folds
1220
+ * the scheme itself and hands the provider a bare token.
1221
+ *
1222
+ * **ABSENCE is deliberately not this function's business.** A missing header
1223
+ * and an unreadable one are different facts, and the callers that care split
1224
+ * on them: the API worker's middleware distinguishes `absent` (the only
1225
+ * anonymous path) from `unreadable` (a presented credential that is refused),
1226
+ * and collapsing the two here would take that distinction away from the layer
1227
+ * that needs it. Callers check for the header themselves and pass its value.
1228
+ *
1229
+ * **Why this lives in the constitution rather than in a worker's `shared/`.**
1230
+ * It is the same wire boundary `classifyToken` already owns — one reads the
1231
+ * slot, the other dispatches on what came out — and a rule with two holders
1232
+ * whose drift is silent earns exactly one owner regardless of what the
1233
+ * convoy costs. The estate's recorded refusal to own a `Bearer` CONSTANT
1234
+ * stands and is a different thing: that is RFC vocabulary, the same reason
1235
+ * this package owns no `"POST"`. A parser is not a spelling.
1236
+ */
1237
+ export declare function readBearerValue(header: string): string | null;
1161
1238
  /**
1162
1239
  * OAuth scope vocabulary for delegated third-party access tokens.
1163
1240
  * Single source of truth used by the authorization server (advertised in
@@ -1243,11 +1320,22 @@ export declare function validateApiKey(apiKey: string): void;
1243
1320
  * Validate deploy token format
1244
1321
  */
1245
1322
  export declare function validateDeployToken(deployToken: string): void;
1323
+ /**
1324
+ * Validate OAuth access token format
1325
+ */
1326
+ export declare function validateOAuthToken(oauthToken: string): void;
1246
1327
  /**
1247
1328
  * Validate a client token of any population. Classifies by shape and applies
1248
- * the matching format rules: `ship-` keys and `deploy-` deploy tokens are
1249
- * validated strictly; opaque tokens (OAuth access tokens, future populations)
1250
- * only need to be non-empty — their validity is the server's to decide.
1329
+ * the matching format rules: all three prefixed populations are validated
1330
+ * strictly; an OPAQUE token only needs to be non-empty.
1331
+ *
1332
+ * **The OPAQUE arm stays permissive on purpose**, even though the platform no
1333
+ * longer mints an unprefixed credential. It is the fallback for a population
1334
+ * that does not exist yet, and a client refusing a shape the server would
1335
+ * accept is the one failure mode this boundary must never have — the server
1336
+ * decides, and it refuses an unrecognised bearer anyway. Unprefixed OAuth
1337
+ * tokens from before 2026-08-14 land here and are refused server-side, which
1338
+ * is correct: they were revoked by the change, not grandfathered.
1251
1339
  */
1252
1340
  export declare function validateToken(token: string): void;
1253
1341
  /**
package/dist/index.js CHANGED
@@ -1015,12 +1015,13 @@ export function hasUnbuiltMarker(filePath) {
1015
1015
  // =============================================================================
1016
1016
  // The one address for credential vocabulary: where human identity lives
1017
1017
  // (AUTH_BASE_PATH), how a request is authorized (AuthMethod), the shapes
1018
- // that distinguish populations on the wire (API_KEY, DEPLOY_TOKEN, CALLER),
1019
- // the single dispatch over them (TokenKind, classifyToken), and the
1020
- // delegated-access scopes (OAuthScope).
1018
+ // that distinguish populations on the wire (API_KEY, DEPLOY_TOKEN,
1019
+ // OAUTH_TOKEN, CALLER), the two halves of the one Bearer slot
1020
+ // (readBearerValue reads it, classifyToken/TokenKind dispatch on what came
1021
+ // out), and the delegated-access scopes (OAuthScope).
1021
1022
  //
1022
1023
  // THE SHAPE LAW, in three clauses, over the `Authorization: Bearer` slot's
1023
- // two populations below. The deployment claim code is the API's own
1024
+ // three populations below. The deployment claim code is the API's own
1024
1025
  // (`AUTH.CLAIM`, server-side: the API mints it and the API validates it, so
1025
1026
  // it has one holder and stays there) and shares only clause 1 — it is the
1026
1027
  // platform's one deliberately BARE secret, because it never enters the
@@ -1037,9 +1038,12 @@ export function hasUnbuiltMarker(filePath) {
1037
1038
  //
1038
1039
  // 2. EVERY BEARER POPULATION IS NAMED BY ITS PREFIX. A credential says what
1039
1040
  // it is before anything parses it — which is what lets `classifyToken`
1040
- // below dispatch two populations sharing one `Authorization: Bearer`
1041
+ // below dispatch three populations sharing one `Authorization: Bearer`
1041
1042
  // slot, and what lets a value found in a log, a support ticket or a
1042
- // pasted URL be recognised and revoked on sight.
1043
+ // pasted URL be recognised and revoked on sight. The OAuth access token
1044
+ // was this clause's one standing exception until 2026-08-14 — the
1045
+ // authorization server it was born on had no mint hook to give it a
1046
+ // prefix, and its successor does.
1043
1047
  //
1044
1048
  // 3. NO PREFIX IS A PREFIX OF ANOTHER. This is what makes the dispatch
1045
1049
  // order-independent, and it is the reason the populations are named on
@@ -1104,6 +1108,39 @@ export const DEPLOY_TOKEN = {
1104
1108
  /** Total length of a deploy token including prefix (`PREFIX.length + HEX_LENGTH = 39`). */
1105
1109
  TOTAL_LENGTH: 39,
1106
1110
  };
1111
+ /**
1112
+ * Shape constants for OAuth access tokens (`oauth-{32 hex chars}`) — the
1113
+ * delegated population, minted by the platform's own authorization server
1114
+ * for a connected app acting on a user's behalf.
1115
+ *
1116
+ * Same width as the other two, and for the same reason: one entropy standard
1117
+ * across the platform, so "how long is a credential" has one answer.
1118
+ *
1119
+ * **This population is the access token alone.** Refresh tokens, authorization
1120
+ * codes and client secrets are deliberately NOT here and are deliberately not
1121
+ * prefixed by this constant: none of them ever enters the `Authorization:
1122
+ * Bearer` slot — a refresh token is posted as a form field to the token
1123
+ * endpoint, which knows what it is receiving — so `classifyToken` never sees
1124
+ * one and a prefix would name a population no dispatcher dispatches. The same
1125
+ * reasoning that keeps the deployment claim code bare.
1126
+ *
1127
+ * **The prefix must be applied at the MINT, never as a display wrapper.** The
1128
+ * authorization server hashes what it stores and the API hashes what it is
1129
+ * presented, so the prefix has to be inside the hashed string on both sides.
1130
+ * `@better-auth/oauth-provider` offers a `prefix.opaqueAccessToken` option
1131
+ * that prepends AFTER hashing and strips on its own read paths; using it would
1132
+ * store a hash of the UNPREFIXED token and silently break the platform's read
1133
+ * arm. The API therefore mints through `generateOpaqueAccessToken` — recorded
1134
+ * beside the config in `cloudflare/api/src/lib/auth/instance.ts`.
1135
+ */
1136
+ export const OAUTH_TOKEN = {
1137
+ /** Prefix that identifies an OAuth access token. */
1138
+ PREFIX: 'oauth-',
1139
+ /** Number of hex characters following the prefix. */
1140
+ HEX_LENGTH: 32,
1141
+ /** Total length including prefix (`PREFIX.length + HEX_LENGTH = 38`). */
1142
+ TOTAL_LENGTH: 38,
1143
+ };
1107
1144
  /**
1108
1145
  * Shape constants for caller identifiers (the `X-Caller` instance-identity
1109
1146
  * header — rate-limit bucketing for multi-tenant orchestrators). The API
@@ -1124,16 +1161,23 @@ export const CALLER = {
1124
1161
  * client token in one wire slot (`Authorization: Bearer <value>`) and
1125
1162
  * classifies by value, never by a side channel — this is the classifier.
1126
1163
  *
1127
- * `API_KEY` and `DEPLOY_TOKEN` *are* `AuthMethod.API_KEY` and
1128
- * `AuthMethod.TOKEN` — the equality is structural, so a classification flows
1129
- * straight into an auth method and the pair can never drift. `OPAQUE` is any
1130
- * other value — shape says nothing about it, so only a lookup can. Today the
1131
- * server refuses every opaque bearer; the OAuth access-token population
1132
- * resolves there when the authorization server ships.
1164
+ * `API_KEY`, `DEPLOY_TOKEN` and `OAUTH` *are* `AuthMethod.API_KEY`,
1165
+ * `AuthMethod.TOKEN` and `AuthMethod.OAUTH` — the equality is structural, so
1166
+ * a classification flows straight into an auth method and the trio can never
1167
+ * drift.
1168
+ *
1169
+ * `OPAQUE` is any other value, and since 2026-08-14 it names NO population:
1170
+ * every credential this platform mints for the Bearer slot carries a prefix,
1171
+ * so an opaque bearer is a bearer we did not mint. It stays a member rather
1172
+ * than becoming a `null` return because a dispatcher with a total codomain
1173
+ * reads better than one with an absence in it — and because it is where a
1174
+ * future population would land before anyone gave it a shape, which is
1175
+ * exactly what the OAuth token itself did until its prefix existed.
1133
1176
  */
1134
1177
  export const TokenKind = {
1135
1178
  API_KEY: AuthMethod.API_KEY,
1136
1179
  DEPLOY_TOKEN: AuthMethod.TOKEN,
1180
+ OAUTH: AuthMethod.OAUTH,
1137
1181
  OPAQUE: 'opaque',
1138
1182
  };
1139
1183
  /**
@@ -1147,8 +1191,53 @@ export function classifyToken(token) {
1147
1191
  return TokenKind.API_KEY;
1148
1192
  if (token.startsWith(DEPLOY_TOKEN.PREFIX))
1149
1193
  return TokenKind.DEPLOY_TOKEN;
1194
+ if (token.startsWith(OAUTH_TOKEN.PREFIX))
1195
+ return TokenKind.OAUTH;
1150
1196
  return TokenKind.OPAQUE;
1151
1197
  }
1198
+ /** The auth-scheme, lowercased — the form the comparison is made in. */
1199
+ const BEARER_SCHEME = 'bearer ';
1200
+ /**
1201
+ * Read the credential out of an `Authorization` header value — the step
1202
+ * BEFORE `classifyToken`, and the other half of the one wire slot this
1203
+ * section owns.
1204
+ *
1205
+ * Returns the credential's own bytes, or `null` when the header carries a
1206
+ * foreign scheme or nothing after the scheme.
1207
+ *
1208
+ * **The scheme is folded; the credential is not.** RFC 7235 §2.1 makes the
1209
+ * auth-scheme case-insensitive, so `bearer`, `Bearer` and `BEARER` are the
1210
+ * same header. The value after it is opaque and is compared literally
1211
+ * everywhere it is used — `ship-`/`deploy-`/`oauth-` are lowercase hex, and
1212
+ * folding them would make a credential match values it is not.
1213
+ *
1214
+ * **This platform has paid for the rule twice, which is why it has an owner
1215
+ * rather than a convention.** A spec-conformant `bearer ship-…` client was
1216
+ * refused for as long as the API's scheme test was spelled case-sensitively;
1217
+ * and `@better-auth/oauth-provider` carries the same defect in four places
1218
+ * today (`startsWith("Bearer ")`), which is precisely why the platform folds
1219
+ * the scheme itself and hands the provider a bare token.
1220
+ *
1221
+ * **ABSENCE is deliberately not this function's business.** A missing header
1222
+ * and an unreadable one are different facts, and the callers that care split
1223
+ * on them: the API worker's middleware distinguishes `absent` (the only
1224
+ * anonymous path) from `unreadable` (a presented credential that is refused),
1225
+ * and collapsing the two here would take that distinction away from the layer
1226
+ * that needs it. Callers check for the header themselves and pass its value.
1227
+ *
1228
+ * **Why this lives in the constitution rather than in a worker's `shared/`.**
1229
+ * It is the same wire boundary `classifyToken` already owns — one reads the
1230
+ * slot, the other dispatches on what came out — and a rule with two holders
1231
+ * whose drift is silent earns exactly one owner regardless of what the
1232
+ * convoy costs. The estate's recorded refusal to own a `Bearer` CONSTANT
1233
+ * stands and is a different thing: that is RFC vocabulary, the same reason
1234
+ * this package owns no `"POST"`. A parser is not a spelling.
1235
+ */
1236
+ export function readBearerValue(header) {
1237
+ if (header.slice(0, BEARER_SCHEME.length).toLowerCase() !== BEARER_SCHEME)
1238
+ return null;
1239
+ return header.slice(BEARER_SCHEME.length) || null;
1240
+ }
1152
1241
  /**
1153
1242
  * OAuth scope vocabulary for delegated third-party access tokens.
1154
1243
  * Single source of truth used by the authorization server (advertised in
@@ -1273,11 +1362,24 @@ export function validateApiKey(apiKey) {
1273
1362
  export function validateDeployToken(deployToken) {
1274
1363
  validatePrefixedCredential(deployToken, DEPLOY_TOKEN, 'Deploy token');
1275
1364
  }
1365
+ /**
1366
+ * Validate OAuth access token format
1367
+ */
1368
+ export function validateOAuthToken(oauthToken) {
1369
+ validatePrefixedCredential(oauthToken, OAUTH_TOKEN, 'OAuth access token');
1370
+ }
1276
1371
  /**
1277
1372
  * Validate a client token of any population. Classifies by shape and applies
1278
- * the matching format rules: `ship-` keys and `deploy-` deploy tokens are
1279
- * validated strictly; opaque tokens (OAuth access tokens, future populations)
1280
- * only need to be non-empty — their validity is the server's to decide.
1373
+ * the matching format rules: all three prefixed populations are validated
1374
+ * strictly; an OPAQUE token only needs to be non-empty.
1375
+ *
1376
+ * **The OPAQUE arm stays permissive on purpose**, even though the platform no
1377
+ * longer mints an unprefixed credential. It is the fallback for a population
1378
+ * that does not exist yet, and a client refusing a shape the server would
1379
+ * accept is the one failure mode this boundary must never have — the server
1380
+ * decides, and it refuses an unrecognised bearer anyway. Unprefixed OAuth
1381
+ * tokens from before 2026-08-14 land here and are refused server-side, which
1382
+ * is correct: they were revoked by the change, not grandfathered.
1281
1383
  */
1282
1384
  export function validateToken(token) {
1283
1385
  switch (classifyToken(token)) {
@@ -1287,6 +1389,9 @@ export function validateToken(token) {
1287
1389
  case TokenKind.DEPLOY_TOKEN:
1288
1390
  validateDeployToken(token);
1289
1391
  return;
1392
+ case TokenKind.OAUTH:
1393
+ validateOAuthToken(token);
1394
+ return;
1290
1395
  case TokenKind.OPAQUE:
1291
1396
  if (!token)
1292
1397
  throw ShipError.validation('Token must be a non-empty string');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipstatic/types",
3
- "version": "2.7.0",
3
+ "version": "2.8.0-beta.2",
4
4
  "description": "Shared types for ShipStatic platform",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/index.ts CHANGED
@@ -1697,12 +1697,13 @@ export interface PingResponse {
1697
1697
  // =============================================================================
1698
1698
  // The one address for credential vocabulary: where human identity lives
1699
1699
  // (AUTH_BASE_PATH), how a request is authorized (AuthMethod), the shapes
1700
- // that distinguish populations on the wire (API_KEY, DEPLOY_TOKEN, CALLER),
1701
- // the single dispatch over them (TokenKind, classifyToken), and the
1702
- // delegated-access scopes (OAuthScope).
1700
+ // that distinguish populations on the wire (API_KEY, DEPLOY_TOKEN,
1701
+ // OAUTH_TOKEN, CALLER), the two halves of the one Bearer slot
1702
+ // (readBearerValue reads it, classifyToken/TokenKind dispatch on what came
1703
+ // out), and the delegated-access scopes (OAuthScope).
1703
1704
  //
1704
1705
  // THE SHAPE LAW, in three clauses, over the `Authorization: Bearer` slot's
1705
- // two populations below. The deployment claim code is the API's own
1706
+ // three populations below. The deployment claim code is the API's own
1706
1707
  // (`AUTH.CLAIM`, server-side: the API mints it and the API validates it, so
1707
1708
  // it has one holder and stays there) and shares only clause 1 — it is the
1708
1709
  // platform's one deliberately BARE secret, because it never enters the
@@ -1719,9 +1720,12 @@ export interface PingResponse {
1719
1720
  //
1720
1721
  // 2. EVERY BEARER POPULATION IS NAMED BY ITS PREFIX. A credential says what
1721
1722
  // it is before anything parses it — which is what lets `classifyToken`
1722
- // below dispatch two populations sharing one `Authorization: Bearer`
1723
+ // below dispatch three populations sharing one `Authorization: Bearer`
1723
1724
  // slot, and what lets a value found in a log, a support ticket or a
1724
- // pasted URL be recognised and revoked on sight.
1725
+ // pasted URL be recognised and revoked on sight. The OAuth access token
1726
+ // was this clause's one standing exception until 2026-08-14 — the
1727
+ // authorization server it was born on had no mint hook to give it a
1728
+ // prefix, and its successor does.
1725
1729
  //
1726
1730
  // 3. NO PREFIX IS A PREFIX OF ANOTHER. This is what makes the dispatch
1727
1731
  // order-independent, and it is the reason the populations are named on
@@ -1793,6 +1797,40 @@ export const DEPLOY_TOKEN = {
1793
1797
  TOTAL_LENGTH: 39,
1794
1798
  } as const;
1795
1799
 
1800
+ /**
1801
+ * Shape constants for OAuth access tokens (`oauth-{32 hex chars}`) — the
1802
+ * delegated population, minted by the platform's own authorization server
1803
+ * for a connected app acting on a user's behalf.
1804
+ *
1805
+ * Same width as the other two, and for the same reason: one entropy standard
1806
+ * across the platform, so "how long is a credential" has one answer.
1807
+ *
1808
+ * **This population is the access token alone.** Refresh tokens, authorization
1809
+ * codes and client secrets are deliberately NOT here and are deliberately not
1810
+ * prefixed by this constant: none of them ever enters the `Authorization:
1811
+ * Bearer` slot — a refresh token is posted as a form field to the token
1812
+ * endpoint, which knows what it is receiving — so `classifyToken` never sees
1813
+ * one and a prefix would name a population no dispatcher dispatches. The same
1814
+ * reasoning that keeps the deployment claim code bare.
1815
+ *
1816
+ * **The prefix must be applied at the MINT, never as a display wrapper.** The
1817
+ * authorization server hashes what it stores and the API hashes what it is
1818
+ * presented, so the prefix has to be inside the hashed string on both sides.
1819
+ * `@better-auth/oauth-provider` offers a `prefix.opaqueAccessToken` option
1820
+ * that prepends AFTER hashing and strips on its own read paths; using it would
1821
+ * store a hash of the UNPREFIXED token and silently break the platform's read
1822
+ * arm. The API therefore mints through `generateOpaqueAccessToken` — recorded
1823
+ * beside the config in `cloudflare/api/src/lib/auth/instance.ts`.
1824
+ */
1825
+ export const OAUTH_TOKEN = {
1826
+ /** Prefix that identifies an OAuth access token. */
1827
+ PREFIX: 'oauth-',
1828
+ /** Number of hex characters following the prefix. */
1829
+ HEX_LENGTH: 32,
1830
+ /** Total length including prefix (`PREFIX.length + HEX_LENGTH = 38`). */
1831
+ TOTAL_LENGTH: 38,
1832
+ } as const;
1833
+
1796
1834
  /**
1797
1835
  * Shape constants for caller identifiers (the `X-Caller` instance-identity
1798
1836
  * header — rate-limit bucketing for multi-tenant orchestrators). The API
@@ -1814,16 +1852,23 @@ export const CALLER = {
1814
1852
  * client token in one wire slot (`Authorization: Bearer <value>`) and
1815
1853
  * classifies by value, never by a side channel — this is the classifier.
1816
1854
  *
1817
- * `API_KEY` and `DEPLOY_TOKEN` *are* `AuthMethod.API_KEY` and
1818
- * `AuthMethod.TOKEN` — the equality is structural, so a classification flows
1819
- * straight into an auth method and the pair can never drift. `OPAQUE` is any
1820
- * other value — shape says nothing about it, so only a lookup can. Today the
1821
- * server refuses every opaque bearer; the OAuth access-token population
1822
- * resolves there when the authorization server ships.
1855
+ * `API_KEY`, `DEPLOY_TOKEN` and `OAUTH` *are* `AuthMethod.API_KEY`,
1856
+ * `AuthMethod.TOKEN` and `AuthMethod.OAUTH` — the equality is structural, so
1857
+ * a classification flows straight into an auth method and the trio can never
1858
+ * drift.
1859
+ *
1860
+ * `OPAQUE` is any other value, and since 2026-08-14 it names NO population:
1861
+ * every credential this platform mints for the Bearer slot carries a prefix,
1862
+ * so an opaque bearer is a bearer we did not mint. It stays a member rather
1863
+ * than becoming a `null` return because a dispatcher with a total codomain
1864
+ * reads better than one with an absence in it — and because it is where a
1865
+ * future population would land before anyone gave it a shape, which is
1866
+ * exactly what the OAuth token itself did until its prefix existed.
1823
1867
  */
1824
1868
  export const TokenKind = {
1825
1869
  API_KEY: AuthMethod.API_KEY,
1826
1870
  DEPLOY_TOKEN: AuthMethod.TOKEN,
1871
+ OAUTH: AuthMethod.OAUTH,
1827
1872
  OPAQUE: 'opaque',
1828
1873
  } as const;
1829
1874
 
@@ -1838,9 +1883,54 @@ export type TokenKindType = (typeof TokenKind)[keyof typeof TokenKind];
1838
1883
  export function classifyToken(token: string): TokenKindType {
1839
1884
  if (token.startsWith(API_KEY.PREFIX)) return TokenKind.API_KEY;
1840
1885
  if (token.startsWith(DEPLOY_TOKEN.PREFIX)) return TokenKind.DEPLOY_TOKEN;
1886
+ if (token.startsWith(OAUTH_TOKEN.PREFIX)) return TokenKind.OAUTH;
1841
1887
  return TokenKind.OPAQUE;
1842
1888
  }
1843
1889
 
1890
+ /** The auth-scheme, lowercased — the form the comparison is made in. */
1891
+ const BEARER_SCHEME = 'bearer ';
1892
+
1893
+ /**
1894
+ * Read the credential out of an `Authorization` header value — the step
1895
+ * BEFORE `classifyToken`, and the other half of the one wire slot this
1896
+ * section owns.
1897
+ *
1898
+ * Returns the credential's own bytes, or `null` when the header carries a
1899
+ * foreign scheme or nothing after the scheme.
1900
+ *
1901
+ * **The scheme is folded; the credential is not.** RFC 7235 §2.1 makes the
1902
+ * auth-scheme case-insensitive, so `bearer`, `Bearer` and `BEARER` are the
1903
+ * same header. The value after it is opaque and is compared literally
1904
+ * everywhere it is used — `ship-`/`deploy-`/`oauth-` are lowercase hex, and
1905
+ * folding them would make a credential match values it is not.
1906
+ *
1907
+ * **This platform has paid for the rule twice, which is why it has an owner
1908
+ * rather than a convention.** A spec-conformant `bearer ship-…` client was
1909
+ * refused for as long as the API's scheme test was spelled case-sensitively;
1910
+ * and `@better-auth/oauth-provider` carries the same defect in four places
1911
+ * today (`startsWith("Bearer ")`), which is precisely why the platform folds
1912
+ * the scheme itself and hands the provider a bare token.
1913
+ *
1914
+ * **ABSENCE is deliberately not this function's business.** A missing header
1915
+ * and an unreadable one are different facts, and the callers that care split
1916
+ * on them: the API worker's middleware distinguishes `absent` (the only
1917
+ * anonymous path) from `unreadable` (a presented credential that is refused),
1918
+ * and collapsing the two here would take that distinction away from the layer
1919
+ * that needs it. Callers check for the header themselves and pass its value.
1920
+ *
1921
+ * **Why this lives in the constitution rather than in a worker's `shared/`.**
1922
+ * It is the same wire boundary `classifyToken` already owns — one reads the
1923
+ * slot, the other dispatches on what came out — and a rule with two holders
1924
+ * whose drift is silent earns exactly one owner regardless of what the
1925
+ * convoy costs. The estate's recorded refusal to own a `Bearer` CONSTANT
1926
+ * stands and is a different thing: that is RFC vocabulary, the same reason
1927
+ * this package owns no `"POST"`. A parser is not a spelling.
1928
+ */
1929
+ export function readBearerValue(header: string): string | null {
1930
+ if (header.slice(0, BEARER_SCHEME.length).toLowerCase() !== BEARER_SCHEME) return null;
1931
+ return header.slice(BEARER_SCHEME.length) || null;
1932
+ }
1933
+
1844
1934
  /**
1845
1935
  * OAuth scope vocabulary for delegated third-party access tokens.
1846
1936
  * Single source of truth used by the authorization server (advertised in
@@ -1988,11 +2078,25 @@ export function validateDeployToken(deployToken: string): void {
1988
2078
  validatePrefixedCredential(deployToken, DEPLOY_TOKEN, 'Deploy token');
1989
2079
  }
1990
2080
 
2081
+ /**
2082
+ * Validate OAuth access token format
2083
+ */
2084
+ export function validateOAuthToken(oauthToken: string): void {
2085
+ validatePrefixedCredential(oauthToken, OAUTH_TOKEN, 'OAuth access token');
2086
+ }
2087
+
1991
2088
  /**
1992
2089
  * Validate a client token of any population. Classifies by shape and applies
1993
- * the matching format rules: `ship-` keys and `deploy-` deploy tokens are
1994
- * validated strictly; opaque tokens (OAuth access tokens, future populations)
1995
- * only need to be non-empty — their validity is the server's to decide.
2090
+ * the matching format rules: all three prefixed populations are validated
2091
+ * strictly; an OPAQUE token only needs to be non-empty.
2092
+ *
2093
+ * **The OPAQUE arm stays permissive on purpose**, even though the platform no
2094
+ * longer mints an unprefixed credential. It is the fallback for a population
2095
+ * that does not exist yet, and a client refusing a shape the server would
2096
+ * accept is the one failure mode this boundary must never have — the server
2097
+ * decides, and it refuses an unrecognised bearer anyway. Unprefixed OAuth
2098
+ * tokens from before 2026-08-14 land here and are refused server-side, which
2099
+ * is correct: they were revoked by the change, not grandfathered.
1996
2100
  */
1997
2101
  export function validateToken(token: string): void {
1998
2102
  switch (classifyToken(token)) {
@@ -2002,6 +2106,9 @@ export function validateToken(token: string): void {
2002
2106
  case TokenKind.DEPLOY_TOKEN:
2003
2107
  validateDeployToken(token);
2004
2108
  return;
2109
+ case TokenKind.OAUTH:
2110
+ validateOAuthToken(token);
2111
+ return;
2005
2112
  case TokenKind.OPAQUE:
2006
2113
  if (!token) throw ShipError.validation('Token must be a non-empty string');
2007
2114
  }