spaps-sdk
v1.13.1
Published
Sweet Potato Authentication & Payment Service SDK - Zero-config client with built-in permission checking, role-based access control, and dayrate scheduling
Maintainers
Readme
spaps-sdk
Typed TypeScript client for SPAPS-compatible APIs.
Examples in this README use placeholders such as [email protected], [email protected], and https://api.example.test. Replace them with values from your own deployment.
Install
npm install spaps-sdkAlternative package managers:
pnpm add spaps-sdk
yarn add spaps-sdkThis package targets Node.js >=14.
When It Fits
| Need | Package gives you |
| --- | --- |
| One client for many SPAPS surfaces | auth, payments, sessions, secureMessages, issueReporting, appLinks, whitelist, users, supportTelemetry, marketing, email, entitlements, usage, access, graph, contract, skillEvals, dayrate, admin, and cfo namespaces |
| Local development without extra config | Localhost URLs automatically enable local mode |
| Browser and server usage | publishableKey, secretKey, or legacy apiKey support |
| Shared contracts | Re-exports a large slice of spaps-types |
Quick Start
import { SPAPSClient } from "spaps-sdk";
const spaps = new SPAPSClient({
apiUrl: "http://localhost:3301",
publishableKey: "spaps_pub_example",
});
const auth = await spaps.auth.signInWithPassword({
email: "[email protected]",
password: "correct-horse-battery-staple",
});
// Accounts with activated TOTP receive an MFA challenge from /api/auth/login;
// complete /api/auth/mfa/verify before using token-backed helpers.
const session = await spaps.getSessionContext();
const issues = await spaps.issueReporting.list({
status: "open",
scope: "mine",
limit: 20,
});
console.log(auth.user.id, session.user.email, session.entitlements.length, issues.total);Issue-reporting reads are user-scoped in the stock SPAPS API. The SDK accepts scope: "mine"
for explicitness and rejects wider scopes until SPAPS exposes a dedicated tenant queue endpoint.
Configuration
Constructor values take precedence over environment variables.
| Option | Purpose |
| --- | --- |
| apiUrl | Base API URL. Localhost values enable local mode automatically |
| publishableKey | Browser-safe key for client-side usage |
| secretKey | Server-side key for privileged access |
| apiKey | Legacy key field kept for compatibility |
| timeout | Request timeout override |
| headerProvider | Optional function returning custom headers to add to every SDK request without overriding Authorization or X-API-Key |
Relevant environment variables:
SPAPS_API_URLNEXT_PUBLIC_SPAPS_API_URLSPAPS_API_KEYNEXT_PUBLIC_SPAPS_API_KEY
Core Surface
| Namespace | Covers |
| --- | --- |
| auth | Password, wallet, OIDC, WebAuthn, MFA, SMS, magic-link, refresh, logout, and password-management flows |
| payments | Checkout sessions, products, prices, subscriptions, and crypto helpers |
| sessions | Session lookup, validation, and lifecycle helpers |
| secureMessages | Secure-message create/list helpers |
| issueReporting | Status, history, create, update, reply, voice-token, and private screenshot attachment flows |
| appLinks | Authenticated create and public resolve helpers for application-scoped short links |
| whitelist | Email whitelist checks and admin mutations |
| users | Batch user/email lookups and app membership administration |
| supportTelemetry | Trusted-service support event ingest and case inspection |
| marketing | Browser-safe attribution/experiment event emission and server-side experiment results |
| email | Template lookup, preview, and send helpers |
| entitlements | User/resource entitlement queries and browser-safe current-user project access reads |
| usage | Secret-key usage authorization and immutable usage recording |
| access | Agent-oriented access decisions, action preparation, and persisted decision traces |
| graph | Secret-key capability graph node, path, impact, explain, and refresh helpers |
| contract | Machine-readable capability graph contract discovery |
| skillEvals | Paid blind skill-eval cases, review rooms, reviewer marks, insight inboxes, and controlled reveal |
| dayrate | Availability, Stripe booking, x402 booking-hold, and checkout-status helpers |
| admin | Product and pricing admin helpers |
| cfo | CFO-facing reporting endpoints |
Common Patterns
Agent Access Decisions
access.check returns a typed decision even when access is denied. Treat
allowed: false as a normal outcome and use next_actions to route the user
or agent to the next step.
const spaps = new SPAPSClient({
apiUrl: "https://api.example.test",
secretKey: process.env.SPAPS_API_KEY,
});
spaps.setAccessToken(process.env.SPAPS_ADMIN_ACCESS_TOKEN!);
const decision = await spaps.access.check({
actor: { actor_type: "user", actor_ref: "user_123" },
action: "checkout.create",
resource: { resource_type: "product", resource_ref: "dayrate" },
controls: { entitlement_key: "bookme_paid" },
});
if (!decision.allowed) {
console.log(decision.outcome, decision.next_actions);
}
const prepared = await spaps.access.prepareAction({
access: {
actor: { actor_ref: "admin_123" },
action: "admin.delete_user",
resource: { resource_type: "user", resource_ref: "user_123" },
},
include_command_templates: true,
environment: "production",
});
console.log(prepared.status, prepared.execution.operator_gate_required);operator-gated is descriptive compatibility input when supplied by a client;
it does not authorize mutation command templates. The server returns mutation
command templates only for a non-publishable key plus an authenticated
admin/operator user context. Publishable callers cannot self-attest the gate.
Use the graph and contract namespaces from trusted server code:
const contract = await spaps.contract.get();
const refresh = await spaps.graph.refresh("local-refresh");
const nodes = await spaps.graph.listNodes({ node_type: "x402_resource", q: "dayrate" });
const explanation = await spaps.access.explain(decision.decision_trace_id);
console.log(contract.version, refresh.status, refresh.diagnostics.phase2_gate?.recommendation);
console.log(nodes.projection?.projection_status, explanation.graph_node_keys);contract.get() also returns graph_node_types, graph_edge_types,
graph_source_domains, and source_domain_notes so agents can discover
stable graph vocabulary such as wallet, api_key, role, and approver.
Capability API failures throw SPAPSSDKError. The error preserves code,
status/statusCode, requestId/request_id, details, diagnostics, and
remediations, including fields returned by the server error envelope.
Browser-Safe Project Access Reads
Use a publishable key and an authenticated user JWT in browser code. These helpers only read project access for the current user; membership invitation, project grant, project revoke, and account capability mutation remain server-only operations.
const spaps = new SPAPSClient({
apiUrl: "https://api.example.test",
publishableKey: "spaps_pub_example",
});
spaps.setAccessToken(userAccessToken);
const projects = await spaps.entitlements.listCurrentUserProjects({
entitlementKey: "pds.project.viewer",
});
const access = await spaps.entitlements.checkCurrentUserProjectAccess({
projectId: "project_123",
entitlementKey: "pds.project.viewer",
});
console.log(projects.count, access.has_access);Modern Auth Methods
Login methods that may require MFA return either token data or an MFA challenge
with mfa_required: true. Complete the challenge with auth.mfa.verify()
before calling token-backed helpers.
const methods = await spaps.auth.getMethods();
console.log(methods.methods.filter((item) => item.enabled).map((item) => item.method));
const login = await spaps.auth.signInWithPassword({
email: "[email protected]",
password: "correct-horse-battery-staple",
});
if ("mfa_required" in login) {
const verified = await spaps.auth.mfa.verify({
challenge_id: login.challenge_id,
challenge: login.challenge,
code: "123456",
});
console.log(verified.user.id);
} else {
console.log(login.user.id);
}OIDC, WebAuthn, and SMS helpers mirror the backend route families without reimplementing browser or provider SDKs:
const oidc = await spaps.auth.oidc.getNonce();
await spaps.auth.oidc.signIn({
provider: "google",
id_token: providerIdToken,
challenge_id: oidc.challenge_id,
});
const createOptions = await spaps.auth.webauthn.registerOptions();
await spaps.auth.webauthn.registerVerify({
challenge_id: createOptions.challenge_id,
credential: serializedPublicKeyCredential,
});
const sms = await spaps.auth.sms.request({ phone_number: "+15555550100" });
await spaps.auth.sms.verify({
phone_number: "+15555550100",
challenge_id: sms.challenge_id,
code: "123456",
});Typed Secure Messages
type SecureMessageMetadata = { urgency: "low" | "high"; tags?: string[] };
const spaps = new SPAPSClient<SecureMessageMetadata>({
apiUrl: "https://api.example.test",
secretKey: process.env.SPAPS_API_KEY,
});
await spaps.secureMessages.create({
patientId: "3d6f0a51-8d77-4b38-8248-2d1b2f1f6c7f",
practitionerId: "a3d7f431-6c9d-4cbc-9f78-4e5b6a7c8d9e",
content: "Follow up scheduled for next week.",
metadata: { urgency: "low", tags: ["follow-up"] },
});Issue Reporting Voice Token
Browser issue-reporting voice input should request a short-lived token from SPAPS instead of exposing an ElevenLabs API key.
const voiceToken = await spaps.issueReporting.createVoiceToken();
console.log(voiceToken.provider, voiceToken.model_id);Issue Reporting Screenshot Attachments
Use spaps.issueReporting.uploadAttachment(file) as the canonical screenshot
upload path. Consumers should not carry raw POST /api/v1/issue-reports/attachments
adapters when their installed spaps-sdk exposes this method.
Screenshots are uploaded as private pending hosted assets first. Create, update, or reply calls then send only attachment IDs; do not put raw image bytes, data URLs, or base64 payloads in issue notes or target metadata.
import {
ISSUE_REPORT_ATTACHMENT_ALLOWED_MIME_TYPES,
ISSUE_REPORT_ATTACHMENT_MAX_BYTES,
ISSUE_REPORT_ATTACHMENT_MAX_RETAINED,
} from "spaps-sdk";
spaps.setAccessToken(accessToken);
const file = new File([pngBytes], "protocol-save-failure.png", {
type: "image/png",
});
const supportedType = ISSUE_REPORT_ATTACHMENT_ALLOWED_MIME_TYPES.some(
(mimeType) => mimeType === file.type,
);
if (!supportedType || file.size > ISSUE_REPORT_ATTACHMENT_MAX_BYTES) {
throw new Error("Screenshot is not a supported SPAPS issue-report attachment");
}
const attachment = await spaps.issueReporting.uploadAttachment(file);
const issue = await spaps.issueReporting.create({
target: {
component_key: "patient_protocol_widget",
component_label: "Patient Protocol Widget",
page_url: "/patients/123/protocol",
surface_ref: "daily-log",
metadata: { section: "daily log" },
},
note: "The save action silently fails after I edit today's protocol note.",
reporter_role_hint: "practitioner",
attachment_ids: [attachment.id],
});
const access = await spaps.issueReporting.getAttachmentAccess(attachment.id);
console.log(issue.id, access.expires_in_seconds, ISSUE_REPORT_ATTACHMENT_MAX_RETAINED);The SDK rejects unsupported screenshot MIME types and files over 10 MiB before opening the multipart request. SPAPS still performs authoritative validation, stores the hosted object privately, and retains at most 5 screenshots per report or reply. Callers fetch a short-lived access URL after normal issue-reporting authorization succeeds. SPAPS does not redact screenshot contents, so host apps should warn users when a capture may include sensitive data.
Browser capture is intentionally outside this SDK. The shared
spaps-issue-reporting-react automatic DOM capture contract is a React-package
concern: it should lazy-load html2canvas only after the host app opts in with
the React screenshotCapture config, convert the visible same-origin DOM result
to a PNG, JPEG, or WebP Blob/File, then pass that file to
uploadAttachment. Apps that do not opt in must not download html2canvas.
If capture or upload fails, submit the issue with metadata and any manual
attachments instead of blocking issue submission.
Version note: uploadAttachment was added in [email protected], and
getAttachmentAccess became the canonical access helper in [email protected].
The exported limit constants shown above are part of this source package line;
wait for the operator-approved npm release before removing local shims from
consumers pinned to older packages.
No new SDK method or backend screenshot endpoint is part of the automatic
capture contract unless the existing private attachment upload proves
insufficient. Worker nodes may validate with local packs or workspace links, but
publishing spaps-sdk or spaps-issue-reporting-react remains operator-gated.
Application Short Links
Use appLinks when a browser app needs a stable public URL for large local state, such as compressed diagram state.
Treat link.username as an opaque public owner segment returned by SPAPS.
const spaps = new SPAPSClient({
apiUrl: "https://api.example.test",
publishableKey: "spaps_pub_example",
});
spaps.setAccessToken(accessToken);
const link = await spaps.appLinks.create({
app_slug: "mmdx",
resource_kind: "mermaid-diagram",
target_path: "/diagrams",
metadata: { diagram_state: "pako:..." },
});
console.log(`/api/v1/app-links/${link.username}/${link.slug}`);
await spaps.appLinks.update(link.username, link.slug, {
metadata: { diagram_state: "pako:new-state" },
});Marketing Events
Use marketing.emit in the browser with a publishable key for anonymous
attribution touches or experiment exposures. Use marketing.getExperimentResults
from a trusted server or agent with a secret key to read revenue-backed results
and the conservative stop signal.
const browserSpaps = new SPAPSClient({
apiUrl: "https://api.example.test",
publishableKey: "spaps_pub_example",
});
await browserSpaps.marketing.emit({
anon_id: "anon_01HY...",
event_type: "experiment_exposure",
experiment_id: "landing-hero-copy",
variant_id: "treatment",
dedupe_key: "landing-hero-copy:anon_01HY:treatment",
timestamp: new Date().toISOString(),
});
const serverSpaps = new SPAPSClient({
apiUrl: "https://api.example.test",
secretKey: process.env.SPAPS_SECRET_KEY,
});
const results = await serverSpaps.marketing.getExperimentResults("landing-hero-copy");
console.log(results.decision.recommendation, results.decision.winner_variant_id);Server-Side Usage Control
Use usage from a trusted backend with a secret key. Browser apps should call
their own backend first; that backend asks SPAPS to authorize the work, performs
the work only on an allow or warning decision, then records the actual usage
with a stable idempotency key.
const spaps = new SPAPSClient({
apiUrl: "https://api.example.test",
secretKey: process.env.SPAPS_SECRET_KEY,
});
const authorization = await spaps.usage.authorize({
feature_key: "assistant_tokens",
resource_type: "company",
resource_id: "company_123",
subject_user_id: "user_123",
dimensions: {
requests: 1,
input_tokens: 1200,
},
});
if (authorization.decision === "blocked") {
throw new Error(authorization.reasons[0]?.message || "Usage not authorized");
}
const result = await runAssistantJob();
await spaps.usage.record({
authorization_ref: authorization.authorization_ref,
idempotency_key: result.jobId,
feature_key: "assistant_tokens",
resource_type: "company",
resource_id: "company_123",
subject_user_id: "user_123",
dimensions: {
input_tokens: result.inputTokens,
output_tokens: result.outputTokens,
},
metadata: {
job_id: result.jobId,
},
});For dashboard and policy views, use spaps.usage.listFeatures(),
spaps.usage.getStatus(...), and spaps.usage.listHistory(...) from the same
trusted backend context.
Skill Evals
Use skillEvals for SPAPS-owned blind review of agent-skill logs. Paid creation uses the same PAYMENT-SIGNATURE header shape as the x402 namespace. Reviewers submit valuable and not_valuable marks, and submitters read those marks through an insight inbox before applying skill changes.
const signedPayment = "base64-x402-payment";
const created = await spaps.skillEvals.createCase(
{
title: "Docs skill comparison",
task_claim: "Compare both implementations.",
success_criteria: ["Finds repo boundaries"],
candidates: [
{
candidate_id: "A",
output_ref: "spaps-artifact://case/a",
evidence_summary: "Validation passed",
artifact_hash: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
artifact_mime: "text/markdown",
jsonl_log_ref: "spaps-artifact://logs/a.jsonl",
jsonl_log_hash: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
skill_ref: "skill://docs-review",
skill_version_ref: "skill://docs-review/v2.0",
skill_version_digest: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
model_id: "openai/gpt-5.4",
effort_level: "medium",
provenance_ref: "skill://private/a",
},
{
candidate_id: "B",
output_ref: "spaps-artifact://case/b",
evidence_summary: "Validation passed",
artifact_hash: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
artifact_mime: "text/markdown",
jsonl_log_ref: "spaps-artifact://logs/b.jsonl",
jsonl_log_hash: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
skill_ref: "skill://docs-review",
skill_version_ref: "skill://docs-review/v2.1",
skill_version_digest: "sha256:2222222222222222222222222222222222222222222222222222222222222222",
model_id: "openai/gpt-5.4",
effort_level: "medium",
provenance_ref: "skill://private/b",
},
],
case_policy: {
access_mode: "team_private",
allowed_model_efforts: [{ model_id: "openai/gpt-5.4", effort_level: "medium" }],
participant_allowlist: ["reviewer-actor-id"],
},
idempotency_key: "eval-create-001",
},
{ paymentSignature: signedPayment }
);
const room = await spaps.skillEvals.getReviewRoom(created.case_id);
console.log(room.reviewer_state);
const review = await spaps.skillEvals.submitReview(created.case_id, {
review_marks: [
{
candidate_id: "B",
kind: "valuable",
note: "B checks the configured docs path before recommending an edit.",
reason_code: "prevents_wrong_repo_patch",
confidence: "high",
criterion: "Finds repo boundaries",
},
],
});
const inbox = await spaps.skillEvals.getInsights(created.case_id);
console.log(inbox.valuable[0]?.jsonl_log_ref, review.review_mark_counts);
await spaps.skillEvals.respondToReview(created.case_id, inbox.valuable[0].source_review_id, {
response: "applied",
reason: "Updated the skill from the concrete log-backed insight.",
applied_insight_ref: inbox.valuable[0].insight_ref,
skill_change_ref: "skill://docs-review/v2.1",
skill_version_before: "skill://docs-review/v2.0",
skill_version_after: "skill://docs-review/v2.1",
jsonl_log_ref: "spaps-artifact://logs/apply.jsonl",
jsonl_log_hash: "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
model_id: "openai/gpt-5.4",
effort_level: "medium",
});Custom Context Headers
Use headerProvider when an app needs to attach tenant, app, or principal context to SDK requests:
const spaps = new SPAPSClient({
apiUrl: "https://api.example.test",
publishableKey: "spaps_pub_example",
headerProvider: () => ({
"X-Tenant-Role": resolveTenantRole(),
"X-App-Slug": "unclawg",
"X-Principal-Id": resolvePrincipalId(),
}),
});The provider runs on each request. Headers named Authorization or X-API-Key are ignored because the SDK manages those from setAccessToken(), publishableKey, secretKey, or apiKey.
Permission Helpers With Explicit Admin Config
import {
canAccessAdmin,
createPermissionChecker,
isAdminAccount,
} from "spaps-sdk";
const customAdmins = ["[email protected]"];
const checker = createPermissionChecker(customAdmins);
const role = checker.getRole("[email protected]");
const adminCheck = canAccessAdmin(
{ id: "user_123", email: "[email protected]" },
customAdmins,
);
const isAdmin = isAdminAccount("[email protected]", customAdmins);
console.log(role, adminCheck.allowed, isAdmin);Runtime Helpers
import {
TokenManager,
isErrorEnvelope,
unwrapEnvelope,
unwrapNestedData,
} from "spaps-sdk";
const payload = TokenManager.decodePayload(accessToken);
const response = unwrapEnvelope(await spaps.health());
const items = unwrapNestedData(listResponse);
if (isErrorEnvelope(errorBody)) {
console.error(errorBody.error.code, errorBody.error.message);
}decodePayload() returns null for invalid JWTs or non-object payloads. The envelope helpers accept unknown API payloads so apps can handle SPAPS success/error envelopes without copying ad hoc response guards.
Convenience Helpers
const spaps = new SPAPSClient({ apiUrl: "http://localhost:3301" });
spaps.isLocalMode();
spaps.isAuthenticated();
spaps.getAccessToken();
spaps.setAccessToken("token");
await spaps.health();Development
cd packages/sdk
npm ci
npm run build
npm run typecheck:readme
npm run test:readme
npm run testTroubleshooting
401 Unauthorized
Check the access token, API key choice, and target environment. Browser apps should generally use publishableKey, not a server secret.
Local mode is not activating
Use a localhost URL such as http://localhost:3301, then confirm with spaps.isLocalMode().
I need shared types in app code
Import them from spaps-sdk if the re-export exists, or install spaps-types directly for a narrower dependency.
Limitations
- The SDK is handwritten around the current SPAPS surface. It is not a generated client for every backend route.
- Some admin and entitlement flows still depend on upstream permissions and token context.
- The legacy
apiKeyfield remains for compatibility, but new integrations should preferpublishableKeyorsecretKey.
FAQ
Does this work in browsers?
Yes. The package is intended for both browser and server use.
Does it include a fetch polyfill?
Yes. It loads cross-fetch/polyfill when fetch is missing.
Can I use it from plain JavaScript?
Yes. The runtime works in JavaScript projects and ships bundled type declarations for TypeScript users.
Does it re-export shared types?
Yes. Many spaps-types exports are re-exported for convenience.
How do I validate the README snippets?
Run:
npm run typecheck:readme
npm run test:readmeMetadata
package_name:spaps-sdklatest_version:1.13.0minimum_runtime:Node.js >=14.0.0api_base_url:https://api.sweetpotato.dev
About Contributions
About Contributions: Please don't take this the wrong way, but I do not accept outside contributions for any of my projects. I simply don't have the mental bandwidth to review anything, and it's my name on the thing, so I'm responsible for any problems it causes; thus, the risk-reward is highly asymmetric from my perspective. I'd also have to worry about other "stakeholders," which seems unwise for tools I mostly make for myself for free. Feel free to submit issues, and even PRs if you want to illustrate a proposed fix, but know I won't merge them directly. Instead, I'll have Claude or Codex review submissions via
ghand independently decide whether and how to address them. Bug reports in particular are welcome. Sorry if this offends, but I want to avoid wasted time and hurt feelings. I understand this isn't in sync with the prevailing open-source ethos that seeks community contributions, but it's the only way I can move at this velocity and keep my sanity.
License
MIT
