diff --git a/datastore/gcs/extensions/datastores/_lib/gcs_cache_sync_test.ts b/datastore/gcs/extensions/datastores/_lib/gcs_cache_sync_test.ts
index 08e62971..10fe0e2d 100644
--- a/datastore/gcs/extensions/datastores/_lib/gcs_cache_sync_test.ts
+++ b/datastore/gcs/extensions/datastores/_lib/gcs_cache_sync_test.ts
@@ -1377,7 +1377,12 @@ Deno.test({
GcsOperationError,
);
assertEquals(err.httpStatusCode, 403);
- assertStringIncludes(err.message, "check GCS credentials");
+ // Issue #226: 403 now leads with the swamp-flavoured credentials-rejected
+ // hint instead of the old generic "check GCS credentials" message.
+ assertStringIncludes(
+ err.message,
+ "Datastore credentials rejected by GCS",
+ );
assertEquals(calls, 1, "403 is terminal — no retries");
} finally {
await shutdown();
diff --git a/datastore/gcs/extensions/datastores/_lib/gcs_client.ts b/datastore/gcs/extensions/datastores/_lib/gcs_client.ts
index 5f8fb6e7..6ea4b8a5 100644
--- a/datastore/gcs/extensions/datastores/_lib/gcs_client.ts
+++ b/datastore/gcs/extensions/datastores/_lib/gcs_client.ts
@@ -140,6 +140,52 @@ export class GcsOperationError extends Error {
}
}
+/**
+ * Classification of GCS-surfaced credential failures. Mirrors
+ * `AwsCredentialErrorKind` from the s3 datastore for symmetry — the user-
+ * facing concept ("session expired" vs "credentials rejected") is the same
+ * across providers, only the remediation command differs.
+ */
+export type GcpCredentialErrorKind =
+ | "session-expired"
+ | "credentials-rejected"
+ | "other";
+
+/**
+ * Classify a GCS error by its derived `causeName` (from the cause-chain) and
+ * HTTP `status`. Pure function — takes primitives so it can be unit-tested
+ * without constructing token-refresh failures or HTTP responses.
+ *
+ * Detection is HTTP-status-only for the credentials-rejected branch — body
+ * shape varies (JSON vs HTML vs plain text on edge cases) and is not
+ * reliable for classification.
+ */
+export function classifyGcpCredentialError(
+ causeName: string | undefined,
+ status: number | undefined | null,
+): GcpCredentialErrorKind {
+ if (causeName === "CredentialsProviderError") return "session-expired";
+ if (status === 401 || status === 403) return "credentials-rejected";
+ return "other";
+}
+
+/**
+ * Render a swamp-flavoured remediation hint for the classified credential
+ * failure. Returns `undefined` for `kind === "other"` so the caller falls
+ * through to existing generic messaging.
+ */
+export function formatGcpCredentialHint(
+ kind: GcpCredentialErrorKind,
+): string | undefined {
+ if (kind === "session-expired") {
+ return "Datastore session expired: your GCP Application Default Credentials have expired or been revoked. Run 'gcloud auth application-default login' to refresh, then retry.";
+ }
+ if (kind === "credentials-rejected") {
+ return "Datastore credentials rejected by GCS: verify GOOGLE_APPLICATION_CREDENTIALS, gcloud ADC, or the attached service account, then retry.";
+ }
+ return undefined;
+}
+
// ---------------------------------------------------------------------------
// ADC token acquisition
// ---------------------------------------------------------------------------
@@ -229,6 +275,50 @@ async function createSignedJwt(
return `${signingInput}.${base64url(signature)}`;
}
+/**
+ * Wrap a token-endpoint failure as a `GcsOperationError`. When the body
+ * indicates `invalid_grant` (refresh token revoked, expired, or never valid),
+ * stamp `name = "CredentialsProviderError"` so the downstream classifier in
+ * `send()` recognises the SSO-equivalent path. Other failures keep a
+ * generic name; the message preserves status + body for debugging.
+ */
+/**
+ * Wrap a token-endpoint failure as a GcsOperationError. Exported for tests
+ * because mocking `oauth2.googleapis.com/token` from outside the module
+ * isn't feasible (the URL is hardcoded in `tokenFromUserCredentials`).
+ * Exporting this lets us prove end-to-end that an `invalid_grant` body
+ * produces the swamp-flavoured "session expired" message.
+ */
+export function tokenRefreshError(
+ context: string,
+ status: number,
+ body: string,
+): GcsOperationError {
+ const isInvalidGrant = body.includes("invalid_grant");
+ const name = isInvalidGrant
+ ? "CredentialsProviderError"
+ : "TokenRefreshError";
+ // Front-load the swamp-flavoured hint when the failure is the GCP-equivalent
+ // of an expired SSO session, so the user sees the cause and remediation
+ // before the raw token-endpoint response.
+ const hint = isInvalidGrant
+ ? formatGcpCredentialHint("session-expired")
+ : undefined;
+ const message = hint
+ ? `${hint} ${context}: ${status} ${body}`
+ : `${context}: ${status} ${body}`;
+ return new GcsOperationError(
+ message,
+ {
+ name,
+ httpStatusCode: status,
+ code: isInvalidGrant ? "invalid_grant" : undefined,
+ bodyPreview: body.slice(0, 256),
+ uploadId: undefined,
+ },
+ );
+}
+
/** Exchange a signed JWT for an access token. */
async function tokenFromServiceAccount(
sa: ServiceAccountKey,
@@ -243,9 +333,10 @@ async function tokenFromServiceAccount(
}),
});
if (!resp.ok) {
- throw new Error(
- `Service account token exchange failed: ${resp.status} ${await resp
- .text()}`,
+ throw tokenRefreshError(
+ "Service account token exchange failed",
+ resp.status,
+ await resp.text(),
);
}
return await resp.json() as TokenResponse;
@@ -266,9 +357,10 @@ async function tokenFromUserCredentials(
}),
});
if (!resp.ok) {
- throw new Error(
- `User credential token refresh failed: ${resp.status} ${await resp
- .text()}`,
+ throw tokenRefreshError(
+ "User credential token refresh failed",
+ resp.status,
+ await resp.text(),
);
}
return await resp.json() as TokenResponse;
@@ -619,9 +711,27 @@ export class GcsClient {
response.headers.get("content-type"),
);
- const parts: string[] = [`GCS ${op} failed`, `HTTP ${response.status}`];
+ const credentialKind = classifyGcpCredentialError(
+ undefined,
+ response.status,
+ );
+ const credentialHint = formatGcpCredentialHint(credentialKind);
+
+ const parts: string[] = [];
+ // Front-load the swamp-flavoured hint so the user sees the cause and
+ // remediation before the SDK's framing of the failure.
+ if (credentialHint) parts.push(credentialHint);
+ parts.push(`GCS ${op} failed`, `HTTP ${response.status}`);
if (code) parts.push(code);
- if (response.status === 401 || response.status === 403) {
+ // Existing generic 401/403 hint stays as fallback when the credential
+ // classifier did not produce a more specific hint. Currently
+ // `classifyGcpCredentialError` covers all 401/403 cases so this branch
+ // is dead today, but kept structurally for future kinds that might
+ // resolve to "other" with status 401/403.
+ if (
+ (response.status === 401 || response.status === 403) &&
+ credentialKind === "other"
+ ) {
parts.push(
"(check GCS credentials — GOOGLE_APPLICATION_CREDENTIALS, gcloud ADC, or attached service account — and project/bucket configuration)",
);
diff --git a/datastore/gcs/extensions/datastores/_lib/gcs_client_test.ts b/datastore/gcs/extensions/datastores/_lib/gcs_client_test.ts
index 4738dbcb..df551e68 100644
--- a/datastore/gcs/extensions/datastores/_lib/gcs_client_test.ts
+++ b/datastore/gcs/extensions/datastores/_lib/gcs_client_test.ts
@@ -33,10 +33,14 @@ import {
assertStringIncludes,
} from "jsr:@std/assert@1.0.19";
import {
+ classifyGcpCredentialError,
+ clearTokenCache,
+ formatGcpCredentialHint,
GcsClient,
GcsOperationError,
NotFoundError,
PreconditionFailedError,
+ tokenRefreshError,
} from "./gcs_client.ts";
/**
@@ -211,7 +215,12 @@ Deno.test({
assertEquals(err.httpStatusCode, 403);
assertEquals(err.code, "authError");
assertStringIncludes(err.message, "HTTP 403");
- assertStringIncludes(err.message, "check GCS credentials");
+ // Issue #226: 403 now leads with the swamp-flavoured credentials-rejected
+ // hint instead of the old generic "check GCS credentials" message.
+ assert(
+ err.message.startsWith("Datastore credentials rejected by GCS"),
+ `expected swamp hint to lead message, got: ${err.message}`,
+ );
assert(
err.bodyPreview && err.bodyPreview.length > 0,
"bodyPreview must be non-empty",
@@ -400,3 +409,228 @@ Deno.test({
}
},
});
+
+// --- Issue #226: SSO/credential errors get a swamp-flavoured hint ---------
+// Pure-helper unit tests (no SDK, no mock server, no env).
+
+Deno.test("classifyGcpCredentialError: CredentialsProviderError → session-expired", () => {
+ assertEquals(
+ classifyGcpCredentialError("CredentialsProviderError", undefined),
+ "session-expired",
+ );
+});
+
+Deno.test("classifyGcpCredentialError: 401 → credentials-rejected", () => {
+ assertEquals(
+ classifyGcpCredentialError(undefined, 401),
+ "credentials-rejected",
+ );
+});
+
+Deno.test("classifyGcpCredentialError: 403 → credentials-rejected", () => {
+ assertEquals(
+ classifyGcpCredentialError(undefined, 403),
+ "credentials-rejected",
+ );
+});
+
+Deno.test("classifyGcpCredentialError: 404 → other (regression)", () => {
+ assertEquals(classifyGcpCredentialError(undefined, 404), "other");
+});
+
+Deno.test("classifyGcpCredentialError: 500 → other (regression)", () => {
+ assertEquals(classifyGcpCredentialError(undefined, 500), "other");
+});
+
+Deno.test("formatGcpCredentialHint: session-expired references gcloud auth", () => {
+ const hint = formatGcpCredentialHint("session-expired");
+ assert(hint !== undefined);
+ assert(hint.startsWith("Datastore session expired"));
+ assert(hint.includes("gcloud auth application-default login"));
+});
+
+Deno.test("formatGcpCredentialHint: credentials-rejected references ADC env vars", () => {
+ const hint = formatGcpCredentialHint("credentials-rejected");
+ assert(hint !== undefined);
+ assert(hint.startsWith("Datastore credentials rejected by GCS"));
+ assert(hint.includes("GOOGLE_APPLICATION_CREDENTIALS"));
+});
+
+Deno.test("formatGcpCredentialHint: other → undefined", () => {
+ assertEquals(formatGcpCredentialHint("other"), undefined);
+});
+
+// --- Wrapper integration tests -------------------------------------------
+//
+// The token-refresh path is exercised by writing a temporary
+// `authorized_user` credentials file, pointing the OAuth token endpoint at
+// our mock server, and intercepting the refresh request. `GOOGLE_APPLICATION_CREDENTIALS`
+// is restored in `finally`. `clearTokenCache()` runs in setup AND teardown
+// because the module-level cache leaks across tests otherwise.
+
+Deno.test({
+ sanitizeResources: false,
+ name: "Issue #226: GCS 401 prepends credentials-rejected hint",
+ fn: async () => {
+ clearTokenCache();
+ const { url, shutdown } = startServer(() =>
+ new Response("unauthorized", {
+ status: 401,
+ headers: { "Content-Type": "text/plain" },
+ })
+ );
+ try {
+ const client = new GcsClient({ bucket: "b", apiEndpoint: url });
+ const err = await assertRejects(
+ () => client.getObject("any"),
+ GcsOperationError,
+ );
+ assertEquals(err.httpStatusCode, 401);
+ assert(
+ err.message.startsWith("Datastore credentials rejected by GCS"),
+ `expected credentials-rejected hint to lead message, got: ${err.message}`,
+ );
+ } finally {
+ await shutdown();
+ clearTokenCache();
+ }
+ },
+});
+
+Deno.test({
+ sanitizeResources: false,
+ name: "Issue #226: GCS 500 does not add credential framing",
+ fn: async () => {
+ clearTokenCache();
+ const { url, shutdown } = startServer(() =>
+ new Response("internal error", {
+ status: 500,
+ headers: { "Content-Type": "text/plain" },
+ })
+ );
+ try {
+ const client = new GcsClient({ bucket: "b", apiEndpoint: url });
+ const err = await assertRejects(
+ () => client.getObject("any"),
+ GcsOperationError,
+ );
+ assertEquals(err.httpStatusCode, 500);
+ assert(
+ !err.message.startsWith("Datastore"),
+ `5xx should not add credential framing, got: ${err.message}`,
+ );
+ } finally {
+ await shutdown();
+ clearTokenCache();
+ }
+ },
+});
+
+// 404/412 fast paths still throw the narrow types — regression guard for
+// the issue #226 changes that did NOT touch lines 675-682 of gcs_client.ts.
+Deno.test({
+ sanitizeResources: false,
+ name: "Issue #226 regression: GCS 404 still throws NotFoundError",
+ fn: async () => {
+ clearTokenCache();
+ const { url, shutdown } = startServer(() =>
+ new Response("not found", { status: 404 })
+ );
+ try {
+ const client = new GcsClient({ bucket: "b", apiEndpoint: url });
+ await assertRejects(() => client.getObject("any"), NotFoundError);
+ } finally {
+ await shutdown();
+ clearTokenCache();
+ }
+ },
+});
+
+Deno.test({
+ sanitizeResources: false,
+ name: "Issue #226 regression: GCS 412 still throws PreconditionFailedError",
+ fn: async () => {
+ clearTokenCache();
+ const { url, shutdown } = startServer(() =>
+ new Response("precondition", { status: 412 })
+ );
+ try {
+ const client = new GcsClient({ bucket: "b", apiEndpoint: url });
+ await assertRejects(
+ () => client.getObject("any"),
+ PreconditionFailedError,
+ );
+ } finally {
+ await shutdown();
+ clearTokenCache();
+ }
+ },
+});
+
+// --- tokenRefreshError unit tests (proves the invalid_grant path) -----------
+//
+// `tokenRefreshError` is what tokenFromUserCredentials and tokenFromServiceAccount
+// call on non-OK token-endpoint responses. We test it directly because the
+// OAuth URL is hardcoded inside those functions and not mockable from outside.
+// Exercising tokenRefreshError end-to-end proves the user-facing behavior:
+// when the body indicates `invalid_grant`, the resulting GcsOperationError
+// has name === "CredentialsProviderError" and a session-expired message.
+
+Deno.test("tokenRefreshError: invalid_grant body produces session-expired hint", () => {
+ const err = tokenRefreshError(
+ "User credential token refresh failed",
+ 400,
+ JSON.stringify({
+ error: "invalid_grant",
+ error_description: "Token has been expired or revoked.",
+ }),
+ );
+ assert(
+ err.message.startsWith("Datastore session expired"),
+ `expected session-expired hint to lead message, got: ${err.message}`,
+ );
+ assert(err.message.includes("gcloud auth application-default login"));
+ assertEquals(err.name, "CredentialsProviderError");
+ assertEquals(err.code, "invalid_grant");
+ assertEquals(err.httpStatusCode, 400);
+});
+
+Deno.test("tokenRefreshError: non-invalid_grant 4xx keeps generic name", () => {
+ const err = tokenRefreshError(
+ "User credential token refresh failed",
+ 400,
+ JSON.stringify({ error: "invalid_request" }),
+ );
+ assert(
+ !err.message.startsWith("Datastore session expired"),
+ `non-invalid_grant should not get session-expired framing, got: ${err.message}`,
+ );
+ assertEquals(err.name, "TokenRefreshError");
+ assertEquals(err.code, undefined);
+});
+
+Deno.test("tokenRefreshError: 5xx without invalid_grant marker", () => {
+ const err = tokenRefreshError(
+ "Service account token exchange failed",
+ 503,
+ "service unavailable",
+ );
+ assertEquals(err.name, "TokenRefreshError");
+ assertEquals(err.httpStatusCode, 503);
+ assert(!err.message.startsWith("Datastore"));
+});
+
+// Composition: end-to-end shape that tokenFromUserCredentials throws when
+// the gcloud ADC refresh token is revoked — exactly the GCS equivalent of
+// the SSO-expired path issue #226 describes for AWS.
+Deno.test("composition: invalid_grant token refresh → session-expired classification", () => {
+ const err = tokenRefreshError(
+ "User credential token refresh failed",
+ 400,
+ '{"error":"invalid_grant","error_description":"reauth required"}',
+ );
+ assertEquals(
+ classifyGcpCredentialError(err.name, err.httpStatusCode),
+ "session-expired",
+ );
+});
diff --git a/datastore/gcs/manifest.yaml b/datastore/gcs/manifest.yaml
index d78829bd..37cd35de 100644
--- a/datastore/gcs/manifest.yaml
+++ b/datastore/gcs/manifest.yaml
@@ -1,6 +1,6 @@
manifestVersion: 1
name: "@swamp/gcs-datastore"
-version: "2026.05.04.1"
+version: "2026.05.04.2"
description: |
Store data in a Google Cloud Storage bucket with local cache synchronization.
Provides distributed locking via GCS generation-based preconditions and
diff --git a/datastore/s3/extensions/datastores/_lib/s3_client.ts b/datastore/s3/extensions/datastores/_lib/s3_client.ts
index cee022a6..31ed3929 100644
--- a/datastore/s3/extensions/datastores/_lib/s3_client.ts
+++ b/datastore/s3/extensions/datastores/_lib/s3_client.ts
@@ -113,6 +113,93 @@ export class S3OperationError extends Error {
}
}
+/**
+ * Classification of AWS-SDK-surfaced credential failures. `session-expired`
+ * means the credential resolver could not produce valid credentials (SSO
+ * token expired, STS session aged out). `credentials-rejected` means
+ * credentials were sent to AWS and explicitly rejected.
+ */
+export type AwsCredentialErrorKind =
+ | "session-expired"
+ | "credentials-rejected"
+ | "other";
+
+/**
+ * Classify an SDK error by its normalized `code` and HTTP `status`. Pure
+ * function — takes primitives so it can be unit-tested without constructing
+ * SDK error shapes. `code` is the value derived inside `wrapError` from
+ * `e.Code ?? e.name ?? cause.name` (with leading-underscore strip for
+ * minified class names).
+ */
+export function classifyAwsCredentialError(
+ code: string | undefined,
+ status: number | undefined,
+): AwsCredentialErrorKind {
+ if (code === "CredentialsProviderError" || code === "ExpiredTokenException") {
+ return "session-expired";
+ }
+ if (
+ code === "InvalidAccessKeyId" ||
+ code === "SignatureDoesNotMatch" ||
+ (status === 403 && code === "AccessDenied")
+ ) {
+ return "credentials-rejected";
+ }
+ return "other";
+}
+
+/**
+ * Derive a normalized error `code` string from an AWS SDK error. The SDK
+ * surfaces codes in three places:
+ *
+ * 1. `e.Code` — XML/JSON-coded error from the service (e.g. `InvalidAccessKeyId`).
+ * 2. `e.name` — the SDK's class name (e.g. `CredentialsProviderError`,
+ * `TimeoutError`). Generic `"Error"` is treated as no signal.
+ * 3. `e.cause.name` — minified SDK builds wrap the real error class behind a
+ * generic outer error, with the underscored class name (e.g.
+ * `_CredentialsProviderError`) on the cause. Strip the leading underscore
+ * so the classifier can match the canonical name.
+ *
+ * Pure function — takes a structural shape so it can be unit-tested without
+ * constructing real SDK errors.
+ */
+export function deriveAwsErrorCode(e: {
+ Code?: string;
+ name?: string;
+ cause?: unknown;
+}): string | undefined {
+ if (e.Code) return e.Code;
+ if (e.name && e.name !== "Error") return e.name;
+ if (e.cause instanceof Error && e.cause.name && e.cause.name !== "Error") {
+ return e.cause.name.replace(/^_+/, "");
+ }
+ return undefined;
+}
+
+/**
+ * Render a swamp-flavoured remediation hint for the classified credential
+ * failure. The hint names the cause in swamp's vocabulary ("datastore session
+ * expired") rather than S3's ("putObjectConditional failed") and points at a
+ * concrete next action. Returns `undefined` for `kind === "other"` so the
+ * caller can fall through to existing generic messaging.
+ */
+export function formatAwsCredentialHint(
+ kind: AwsCredentialErrorKind,
+ awsProfile: string | undefined,
+): string | undefined {
+ if (kind === "session-expired") {
+ const cmd = awsProfile
+ ? `aws sso login --profile ${awsProfile}`
+ : `aws sso login`;
+ return `Datastore session expired: your AWS profile's SSO session is no longer valid. Run '${cmd}' to refresh, then retry.`;
+ }
+ if (kind === "credentials-rejected") {
+ const who = awsProfile ? `'${awsProfile}'` : `your AWS profile`;
+ return `Datastore credentials rejected by AWS: verify ${who}, environment variables, or credential provider, then retry.`;
+ }
+ return undefined;
+}
+
/**
* Drain a Node Readable into a Uint8Array, capped at `maxBytes`. The AWS
* SDK's default Node request handler (used under Deno-npm) returns an
@@ -356,10 +443,20 @@ export class S3Client {
};
const status = e.$metadata?.httpStatusCode;
const requestId = e.$metadata?.requestId;
- const code = e.Code ?? (e.name !== "Error" ? e.name : undefined);
+ const code = deriveAwsErrorCode(e);
const preview = e.$response?.__errorBodyPreview;
- const parts: string[] = [`S3 ${op} failed`];
+ const credentialKind = classifyAwsCredentialError(code, status);
+ const credentialHint = formatAwsCredentialHint(
+ credentialKind,
+ Deno.env.get("AWS_PROFILE"),
+ );
+
+ const parts: string[] = [];
+ // Front-load the swamp-flavoured hint so the user sees the cause and
+ // remediation before the SDK's framing of the failure.
+ if (credentialHint) parts.push(credentialHint);
+ parts.push(`S3 ${op} failed`);
// Signal-triggered aborts carry no HTTP status; surface the timeout
// context so callers can distinguish "timed out" from "service 5xx".
// `AbortSignal.timeout()` triggers a DOMException with name
@@ -373,7 +470,10 @@ export class S3Client {
if (code && code !== "Unknown") parts.push(code);
const rawMsg = e.message && e.message !== "UnknownError" ? e.message : "";
if (rawMsg) parts.push(`— ${rawMsg}`);
- if (status === 401 || status === 403) {
+ // The generic 401/403 hint stays as a fallback for non-credential auth
+ // failures (e.g. BucketRegionMismatch surfaced as 403). Skip it when the
+ // credential classifier already produced a more specific hint.
+ if ((status === 401 || status === 403) && credentialKind === "other") {
parts.push(
"(check AWS credentials — profile, env vars, or credential provider — and endpoint configuration)",
);
diff --git a/datastore/s3/extensions/datastores/_lib/s3_client_test.ts b/datastore/s3/extensions/datastores/_lib/s3_client_test.ts
index ac3cf8fc..3bc1703f 100644
--- a/datastore/s3/extensions/datastores/_lib/s3_client_test.ts
+++ b/datastore/s3/extensions/datastores/_lib/s3_client_test.ts
@@ -18,7 +18,33 @@
// along with Swamp. If not, see .
import { assert, assertEquals } from "jsr:@std/assert@1.0.19";
-import { S3Client, S3OperationError } from "./s3_client.ts";
+import {
+ classifyAwsCredentialError,
+ deriveAwsErrorCode,
+ formatAwsCredentialHint,
+ S3Client,
+ S3OperationError,
+} from "./s3_client.ts";
+
+/**
+ * Run `fn` with `AWS_PROFILE` set to `value` (or unset when `value` is
+ * undefined), restoring the prior value in `finally`. Used by tests that
+ * exercise the profile-aware credential hint.
+ */
+async function withAwsProfile(
+ value: string | undefined,
+ fn: () => Promise,
+): Promise {
+ const prior = Deno.env.get("AWS_PROFILE");
+ if (value === undefined) Deno.env.delete("AWS_PROFILE");
+ else Deno.env.set("AWS_PROFILE", value);
+ try {
+ await fn();
+ } finally {
+ if (prior !== undefined) Deno.env.set("AWS_PROFILE", prior);
+ else Deno.env.delete("AWS_PROFILE");
+ }
+}
/**
* Start a local HTTP server that produces a fixed response, and run `fn`
@@ -311,3 +337,435 @@ Deno.test({
},
),
});
+
+// --- Issue #226: SSO/credential errors get a swamp-flavoured hint ---------
+// Pure-helper unit tests (no SDK, no mock server, no env).
+
+Deno.test("classifyAwsCredentialError: CredentialsProviderError → session-expired", () => {
+ assertEquals(
+ classifyAwsCredentialError("CredentialsProviderError", undefined),
+ "session-expired",
+ );
+});
+
+Deno.test("classifyAwsCredentialError: ExpiredTokenException → session-expired", () => {
+ assertEquals(
+ classifyAwsCredentialError("ExpiredTokenException", undefined),
+ "session-expired",
+ );
+});
+
+Deno.test("classifyAwsCredentialError: InvalidAccessKeyId → credentials-rejected", () => {
+ assertEquals(
+ classifyAwsCredentialError("InvalidAccessKeyId", 403),
+ "credentials-rejected",
+ );
+});
+
+Deno.test("classifyAwsCredentialError: SignatureDoesNotMatch → credentials-rejected", () => {
+ assertEquals(
+ classifyAwsCredentialError("SignatureDoesNotMatch", 403),
+ "credentials-rejected",
+ );
+});
+
+Deno.test("classifyAwsCredentialError: AccessDenied + 403 → credentials-rejected", () => {
+ assertEquals(
+ classifyAwsCredentialError("AccessDenied", 403),
+ "credentials-rejected",
+ );
+});
+
+// 401 is a status guard: AccessDenied is the 403 shape; a 401 is a different
+// failure mode (presigned URL expired, etc.) and should not be classified
+// as credentials-rejected by `code` alone.
+Deno.test("classifyAwsCredentialError: AccessDenied + 401 → other (status guard)", () => {
+ assertEquals(
+ classifyAwsCredentialError("AccessDenied", 401),
+ "other",
+ );
+});
+
+Deno.test("classifyAwsCredentialError: BucketRegionMismatch + 403 → other (regression)", () => {
+ assertEquals(
+ classifyAwsCredentialError("BucketRegionMismatch", 403),
+ "other",
+ );
+});
+
+Deno.test("classifyAwsCredentialError: undefined code + 403 → other", () => {
+ assertEquals(
+ classifyAwsCredentialError(undefined, 403),
+ "other",
+ );
+});
+
+Deno.test("formatAwsCredentialHint: session-expired with profile renders --profile flag", () => {
+ const hint = formatAwsCredentialHint("session-expired", "demo");
+ assert(hint !== undefined);
+ assert(
+ hint.includes("aws sso login --profile demo"),
+ `expected --profile in hint, got: ${hint}`,
+ );
+ assert(hint.startsWith("Datastore session expired"));
+});
+
+Deno.test("formatAwsCredentialHint: session-expired without profile renders generic command", () => {
+ const hint = formatAwsCredentialHint("session-expired", undefined);
+ assert(hint !== undefined);
+ assert(hint.includes("aws sso login"));
+ assert(
+ !hint.includes("--profile"),
+ `expected no --profile flag when AWS_PROFILE unset, got: ${hint}`,
+ );
+});
+
+Deno.test("formatAwsCredentialHint: credentials-rejected with profile names it", () => {
+ const hint = formatAwsCredentialHint("credentials-rejected", "demo");
+ assert(hint !== undefined);
+ assert(hint.includes("'demo'"));
+ assert(hint.startsWith("Datastore credentials rejected by AWS"));
+});
+
+Deno.test("formatAwsCredentialHint: credentials-rejected without profile uses generic phrasing", () => {
+ const hint = formatAwsCredentialHint("credentials-rejected", undefined);
+ assert(hint !== undefined);
+ assert(hint.includes("your AWS profile"));
+});
+
+Deno.test("formatAwsCredentialHint: other → undefined (caller falls through)", () => {
+ assertEquals(
+ formatAwsCredentialHint("other", "demo"),
+ undefined,
+ );
+});
+
+// --- Wrapper integration tests via withMockServer for server-side errors ---
+//
+// These tests deliberately do NOT set AWS_PROFILE — when AWS_PROFILE is set,
+// the AWS SDK's credential resolver attempts to load that named profile
+// from ~/.aws/credentials before falling through to the env-var keys
+// withMockServer provides, which causes spurious CredentialsProviderError
+// failures locally. The profile-name branches of formatAwsCredentialHint
+// are covered by the pure-function unit tests above.
+
+Deno.test({
+ sanitizeResources: false,
+ name: "InvalidAccessKeyId 403 prepends credentials-rejected hint",
+ fn: () =>
+ withAwsProfile(undefined, () =>
+ withMockServer(
+ () =>
+ new Response(
+ 'InvalidAccessKeyIdThe AWS Access Key Id you provided does not exist in our records.req-iaki',
+ {
+ status: 403,
+ headers: {
+ "Content-Type": "application/xml",
+ "x-amz-request-id": "req-iaki",
+ },
+ },
+ ),
+ async (client) => {
+ let caught: unknown;
+ try {
+ await client.getObject("k");
+ } catch (e) {
+ caught = e;
+ }
+ assert(caught instanceof S3OperationError);
+ const err = caught as S3OperationError;
+ assert(
+ err.message.startsWith("Datastore credentials rejected by AWS"),
+ `expected swamp hint to lead message, got: ${err.message}`,
+ );
+ // .name preserved so existing `error.name === "InvalidAccessKeyId"`
+ // checks at call sites keep working.
+ assertEquals(err.name, "InvalidAccessKeyId");
+ // Existing generic 401/403 hint should NOT also fire — the
+ // credential-specific hint replaces it.
+ assert(
+ !err.message.includes("(check AWS credentials"),
+ `generic 401/403 hint should be suppressed when credential-specific hint fires, got: ${err.message}`,
+ );
+ },
+ )),
+});
+
+Deno.test({
+ sanitizeResources: false,
+ name: "AccessDenied 403 prepends credentials-rejected hint",
+ fn: () =>
+ withAwsProfile(undefined, () =>
+ withMockServer(
+ () =>
+ new Response(
+ 'AccessDeniedaccess deniedr',
+ {
+ status: 403,
+ headers: { "Content-Type": "application/xml" },
+ },
+ ),
+ async (client) => {
+ let caught: unknown;
+ try {
+ await client.getObject("k");
+ } catch (e) {
+ caught = e;
+ }
+ const err = caught as S3OperationError;
+ assert(
+ err.message.startsWith("Datastore credentials rejected by AWS"),
+ `got: ${err.message}`,
+ );
+ assertEquals(err.name, "AccessDenied");
+ },
+ )),
+});
+
+Deno.test({
+ sanitizeResources: false,
+ name: "SignatureDoesNotMatch 403 prepends credentials-rejected hint",
+ fn: () =>
+ withAwsProfile(undefined, () =>
+ withMockServer(
+ () =>
+ new Response(
+ 'SignatureDoesNotMatchsig mismatchr',
+ {
+ status: 403,
+ headers: { "Content-Type": "application/xml" },
+ },
+ ),
+ async (client) => {
+ let caught: unknown;
+ try {
+ await client.getObject("k");
+ } catch (e) {
+ caught = e;
+ }
+ const err = caught as S3OperationError;
+ assert(err.message.startsWith("Datastore credentials rejected"));
+ assertEquals(err.name, "SignatureDoesNotMatch");
+ },
+ )),
+});
+
+// Regression guard: a non-credential 403 (e.g. BucketRegionMismatch) still
+// gets the existing generic auth hint, not the credential-specific hint.
+Deno.test({
+ sanitizeResources: false,
+ name: "BucketRegionMismatch 403 falls through to generic auth hint",
+ fn: () =>
+ withAwsProfile(undefined, () =>
+ withMockServer(
+ () =>
+ new Response(
+ 'BucketRegionMismatchwrong regionr',
+ {
+ status: 403,
+ headers: { "Content-Type": "application/xml" },
+ },
+ ),
+ async (client) => {
+ let caught: unknown;
+ try {
+ await client.getObject("k");
+ } catch (e) {
+ caught = e;
+ }
+ const err = caught as S3OperationError;
+ assert(
+ !err.message.startsWith("Datastore credentials rejected"),
+ `BucketRegionMismatch should not be classified as credentials-rejected, got: ${err.message}`,
+ );
+ assert(
+ !err.message.startsWith("Datastore session expired"),
+ `BucketRegionMismatch should not be classified as session-expired, got: ${err.message}`,
+ );
+ assert(
+ err.message.includes("(check AWS credentials"),
+ `generic auth hint should still fire for non-credential 403, got: ${err.message}`,
+ );
+ },
+ )),
+});
+
+// 5xx regression guard: no credential framing on server errors.
+Deno.test({
+ sanitizeResources: false,
+ name: "500 response does not add credential framing",
+ fn: () =>
+ withAwsProfile(undefined, () =>
+ withMockServer(
+ () =>
+ new Response("internal error", {
+ status: 500,
+ headers: { "Content-Type": "text/plain" },
+ }),
+ async (client) => {
+ let caught: unknown;
+ try {
+ await client.getObject("k");
+ } catch (e) {
+ caught = e;
+ }
+ const err = caught as S3OperationError;
+ assert(!err.message.startsWith("Datastore"));
+ },
+ )),
+});
+
+// --- deriveAwsErrorCode unit tests (cause-chain handling) ----------------
+
+Deno.test("deriveAwsErrorCode: prefers e.Code", () => {
+ assertEquals(
+ deriveAwsErrorCode({
+ Code: "InvalidAccessKeyId",
+ name: "Error",
+ }),
+ "InvalidAccessKeyId",
+ );
+});
+
+Deno.test("deriveAwsErrorCode: falls back to e.name when set", () => {
+ assertEquals(
+ deriveAwsErrorCode({ name: "CredentialsProviderError" }),
+ "CredentialsProviderError",
+ );
+});
+
+Deno.test("deriveAwsErrorCode: ignores generic 'Error' name", () => {
+ assertEquals(deriveAwsErrorCode({ name: "Error" }), undefined);
+});
+
+// Issue #226 stack trace shows `_CredentialsProviderError` on the cause.
+Deno.test("deriveAwsErrorCode: walks cause chain and strips leading underscore", () => {
+ const inner = new Error("Token is expired");
+ inner.name = "_CredentialsProviderError";
+ assertEquals(
+ deriveAwsErrorCode({ name: "Error", cause: inner }),
+ "CredentialsProviderError",
+ );
+});
+
+Deno.test("deriveAwsErrorCode: strips multiple leading underscores", () => {
+ const inner = new Error("x");
+ inner.name = "__CredentialsProviderError";
+ assertEquals(
+ deriveAwsErrorCode({ name: "Error", cause: inner }),
+ "CredentialsProviderError",
+ );
+});
+
+Deno.test("deriveAwsErrorCode: ignores non-Error cause", () => {
+ assertEquals(
+ deriveAwsErrorCode({ name: "Error", cause: { name: "Foo" } }),
+ undefined,
+ );
+});
+
+Deno.test("deriveAwsErrorCode: returns undefined when no signal anywhere", () => {
+ assertEquals(deriveAwsErrorCode({}), undefined);
+ assertEquals(deriveAwsErrorCode({ name: "Error" }), undefined);
+});
+
+// Composition: cause-chain CredentialsProviderError flows through to the
+// classifier via deriveAwsErrorCode → classifyAwsCredentialError → "session-expired".
+Deno.test("composition: cause-chain _CredentialsProviderError classifies as session-expired", () => {
+ const inner = new Error("Token is expired");
+ inner.name = "_CredentialsProviderError";
+ const code = deriveAwsErrorCode({ name: "Error", cause: inner });
+ assertEquals(code, "CredentialsProviderError");
+ assertEquals(
+ classifyAwsCredentialError(code, undefined),
+ "session-expired",
+ );
+});
+
+// --- Client-side CredentialsProviderError integration test ----------------
+//
+// Issue #226's primary path: the SDK throws CredentialsProviderError before
+// any HTTP call when the credential resolver fails. Reproduce by pointing
+// AWS_PROFILE at a profile that doesn't exist, unsetting all env-var keys,
+// and disabling IMDS so the chain exhausts quickly. The first SDK call
+// then surfaces CredentialsProviderError, which wrapError must classify as
+// session-expired and prepend the swamp-flavoured hint.
+
+Deno.test({
+ sanitizeResources: false,
+ // Disable op sanitization too — the SDK's credential chain may leave
+ // pending IO ops on the failed IMDS lookup despite AWS_EC2_METADATA_DISABLED.
+ sanitizeOps: false,
+ name:
+ "Issue #226: client-side CredentialsProviderError surfaces session-expired hint",
+ fn: async () => {
+ const priorAccessKey = Deno.env.get("AWS_ACCESS_KEY_ID");
+ const priorSecret = Deno.env.get("AWS_SECRET_ACCESS_KEY");
+ const priorSession = Deno.env.get("AWS_SESSION_TOKEN");
+ const priorProfile = Deno.env.get("AWS_PROFILE");
+ const priorImds = Deno.env.get("AWS_EC2_METADATA_DISABLED");
+ const priorConfig = Deno.env.get("AWS_CONFIG_FILE");
+ const priorCreds = Deno.env.get("AWS_SHARED_CREDENTIALS_FILE");
+ Deno.env.delete("AWS_ACCESS_KEY_ID");
+ Deno.env.delete("AWS_SECRET_ACCESS_KEY");
+ Deno.env.delete("AWS_SESSION_TOKEN");
+ Deno.env.set("AWS_PROFILE", "swamp-issue-226-nonexistent-profile");
+ Deno.env.set("AWS_EC2_METADATA_DISABLED", "true");
+ // Point at empty files so the SDK doesn't accidentally pick up a real
+ // matching profile from the developer's home directory.
+ const emptyFile = await Deno.makeTempFile({ prefix: "swamp-empty-" });
+ Deno.env.set("AWS_CONFIG_FILE", emptyFile);
+ Deno.env.set("AWS_SHARED_CREDENTIALS_FILE", emptyFile);
+
+ try {
+ const client = new S3Client({
+ bucket: "test-bucket",
+ region: "us-east-1",
+ });
+ let caught: unknown;
+ try {
+ await client.headBucket();
+ } catch (e) {
+ caught = e;
+ }
+ assert(
+ caught instanceof S3OperationError,
+ `expected S3OperationError, got: ${caught}`,
+ );
+ const err = caught as S3OperationError;
+ assert(
+ err.message.startsWith("Datastore session expired"),
+ `expected session-expired hint to lead message, got: ${err.message}`,
+ );
+ assert(
+ err.message.includes("aws sso login"),
+ `expected aws sso login remediation, got: ${err.message}`,
+ );
+ assert(
+ err.message.includes("--profile swamp-issue-226-nonexistent-profile"),
+ `expected --profile flag with the configured profile, got: ${err.message}`,
+ );
+ // .name preserved so existing `error.name === "CredentialsProviderError"`
+ // checks at call sites keep working.
+ assert(
+ err.name === "CredentialsProviderError" ||
+ err.name.endsWith("CredentialsProviderError"),
+ `expected name to reflect underlying SDK error, got: ${err.name}`,
+ );
+ } finally {
+ await Deno.remove(emptyFile).catch(() => {});
+ if (priorAccessKey) Deno.env.set("AWS_ACCESS_KEY_ID", priorAccessKey);
+ if (priorSecret) Deno.env.set("AWS_SECRET_ACCESS_KEY", priorSecret);
+ if (priorSession) Deno.env.set("AWS_SESSION_TOKEN", priorSession);
+ if (priorProfile) Deno.env.set("AWS_PROFILE", priorProfile);
+ else Deno.env.delete("AWS_PROFILE");
+ if (priorImds) Deno.env.set("AWS_EC2_METADATA_DISABLED", priorImds);
+ else Deno.env.delete("AWS_EC2_METADATA_DISABLED");
+ if (priorConfig) Deno.env.set("AWS_CONFIG_FILE", priorConfig);
+ else Deno.env.delete("AWS_CONFIG_FILE");
+ if (priorCreds) Deno.env.set("AWS_SHARED_CREDENTIALS_FILE", priorCreds);
+ else Deno.env.delete("AWS_SHARED_CREDENTIALS_FILE");
+ }
+ },
+});
diff --git a/datastore/s3/manifest.yaml b/datastore/s3/manifest.yaml
index 985d1ba8..0fe5a67d 100644
--- a/datastore/s3/manifest.yaml
+++ b/datastore/s3/manifest.yaml
@@ -1,6 +1,6 @@
manifestVersion: 1
name: "@swamp/s3-datastore"
-version: "2026.05.04.1"
+version: "2026.05.04.2"
description: |
Store data in an Amazon S3 bucket with local cache synchronization.
Provides distributed locking via S3 conditional writes and bidirectional