Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions supabase/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Supabase CLI local state — never commit
.branches/
.temp/
# snippets/ and functions/ hold committed content (.gitkeep at minimum): Podman does not
# auto-create missing bind-mount directories, so fresh clones must already have them.
187 changes: 187 additions & 0 deletions supabase/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "bloom-team-collections"

[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get
# API endpoints. `public` and `graphql_public` schemas are always included.
schemas = ["public", "graphql_public", "tc"]
# Extra schemas to add to the search_path of every request. `public` is always included.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload
# size for accidental or malicious requests.
max_rows = 1000

[api.tls]
enabled = false

[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# The database major version to use. This has to be the same as your remote database's. Run
# `SHOW server_version;` on the remote database to check.
major_version = 15

# Dev seed: creates the standard dev users (admin/alice/bob@dev.local) — see server/dev/README.md
[db.seed]
enabled = true
sql_paths = ["../server/dev/seed.sql"]

[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100

[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv6)
# ip_version = "IPv6"
# The minimum JWT expiry to allow for realtime subscriptions (in seconds).
# min_message_expiry_ms = 1000
# Maximum number of channels per client.
# max_channels_per_client = 100
# Timeout for auth checks (in milliseconds).
# experimental_broadcast_ack_ms = 300

[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
openai_api_key = "env(OPENAI_API_KEY)"

# Email testing server. Emails sent with the local dev setup are not actually sent - rather,
# they are monitored, and you can view the emails that would have been sent from the web
# interface on inbucket.
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
smtp_port = 54325
pop3_port = 54326

[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"

[storage.image_transformation]
enabled = true

# Uncomment to use a custom domain for storage uploads
# [storage.s3_protocol]
# enabled = true
# endpoint = "env(SUPABASE_STORAGE_S3_ENDPOINT)"

[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs
# used in emails.
site_url = "http://127.0.0.1:3000"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://127.0.0.1:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1
# week).
jwt_expiry = 3600
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows session invalidation from server-side. (default: true)
enable_signup = true
# IMPORTANT: for Cloud TC dev, auto-confirm is ON so any login is "verified"
# (mimics a backend that accepts any sign-in). Real Firebase/BloomLibrary auth plugs
# in behind CloudAuth later; tc.jwt_email_verified() on the server handles both shapes.
[auth.email]
enable_signup = true
double_confirm_changes = true
enable_confirmations = false # dev: auto-confirm = no email loop needed

[auth.sms]
enable_signup = false

[auth.external.apple]
enabled = false

[auth.external.azure]
enabled = false

[auth.external.bitbucket]
enabled = false

[auth.external.discord]
enabled = false

[auth.external.facebook]
enabled = false

[auth.external.figma]
enabled = false

[auth.external.github]
enabled = false

[auth.external.gitlab]
enabled = false

[auth.external.google]
enabled = false

[auth.external.keycloak]
enabled = false

[auth.external.linkedin_oidc]
enabled = false

[auth.external.notion]
enabled = false

[auth.external.twitch]
enabled = false

[auth.external.twitter]
enabled = false

[auth.external.slack_oidc]
enabled = false

[auth.external.spotify]
enabled = false

[auth.external.workos]
enabled = false

[auth.external.zoom]
enabled = false

[edge_runtime]
enabled = true
# Configure one of the supported request policies: `oneshot`, `per_worker`.
# Use `oneshot` for hot reload (recommended for development), `per_worker` for production.
# NOTE (task 02, 6 Jul 2026): `oneshot` re-transpiles/type-checks the whole module graph
# — including the heavy npm:@aws-sdk/client-s3 + client-sts imports in
# supabase/functions/_shared/s3.ts — on EVERY request. That cold-start regularly exceeds
# the edge-runtime's fixed ~10s worker-boot timeout (InvalidWorkerCreation: "worker did
# not respond in time"), which made checkin-start/etc. fail every call locally. `per_worker`
# compiles once and reuses the worker, which is also a closer match to how these functions
# actually run in production. See server/dev/README.md.
policy = "per_worker"
inspector_port = 8083

[analytics]
enabled = false
port = 54327
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
Empty file added supabase/functions/.gitkeep
Empty file.
83 changes: 83 additions & 0 deletions supabase/functions/_shared/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Environment / configuration helpers shared by every Cloud Team Collections edge
// function. Centralised here so the dev/prod credential-provider seam (see
// server/dev/DEV-CREDENTIALS.md) lives in exactly one place.

/** Reads a required env var; throws (fails fast) if missing — see AGENTS.md testing
* philosophy: don't silently work around a missing dependency. */
export const requireEnv = (name: string): string => {
const value = Deno.env.get(name);
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
};

/** Optional env var with a default. */
export const optionalEnv = (name: string, fallback: string): string =>
Deno.env.get(name) ?? fallback;

/** True when running against the LOCAL stack (MinIO via AssumeRole, on the developer's own
* machine) rather than real AWS. Mirrors server/dev/DEV-CREDENTIALS.md's
* `BLOOM_CLOUD_LOCAL_MODE` secret. Named "local", not "dev": in the Bloom ecosystem "dev"
* conventionally means a REAL, hosted, reserved-for-testing deployment (dev.bloomlibrary.org),
* and a future cloud-collections project will likely carry "dev" in its name — a hosted "dev"
* deployment runs with this flag FALSE. */
export const isLocalMode = (): boolean =>
optionalEnv("BLOOM_CLOUD_LOCAL_MODE", "false") === "true";

/** Supabase project URL + anon key, auto-injected by the Supabase CLI/platform into
* every edge function's environment — used to call PostgREST RPCs with the caller's
* OWN forwarded JWT (never the service-role key; see the migration's header comment
* for why that is both sufficient and correct here). */
export const supabaseUrl = (): string => requireEnv("SUPABASE_URL");
export const supabaseAnonKey = (): string => requireEnv("SUPABASE_ANON_KEY");

/** S3 / MinIO connection details. */
export interface S3Env {
endpoint: string;
bucket: string;
region: string;
forcePathStyle: boolean;
}

export const s3Env = (): S3Env => ({
endpoint: requireEnv("BLOOM_S3_ENDPOINT"),
bucket: requireEnv("BLOOM_S3_BUCKET"),
region: optionalEnv("BLOOM_S3_REGION", "us-east-1"),
// MinIO requires path-style; real AWS uses virtual-hosted style. Local mode always
// forces path-style; production can opt out via BLOOM_S3_FORCE_PATH_STYLE=false.
forcePathStyle:
isLocalMode() ||
optionalEnv("BLOOM_S3_FORCE_PATH_STYLE", "true") === "true",
});

/** Root/admin credentials used ONLY server-side (never sent to a client) to call
* MinIO's AssumeRole STS endpoint in local mode, and for admin S3 operations
* (HeadObject / GetObjectAttributes verification, .manifest.json writes). */
export const minioRootCredentials = () => ({
accessKeyId: optionalEnv("BLOOM_S3_ROOT_ACCESS_KEY", "minioadmin"),
secretAccessKey: optionalEnv("BLOOM_S3_ROOT_SECRET_KEY", "minioadmin"),
});

/** Production broker configuration: the "assume-only" IAM user's credentials and the
* bloom-teams-broker role ARN it is allowed to assume (see server/provision-aws.ts). */
export const prodBrokerConfig = () => ({
roleArn: requireEnv("BLOOM_TEAMS_BROKER_ROLE_ARN"),
accessKeyId: requireEnv("AWS_ACCESS_KEY_ID"),
secretAccessKey: requireEnv("AWS_SECRET_ACCESS_KEY"),
region: optionalEnv("AWS_REGION", "us-east-1"),
});

/** Admin S3 credentials for server-side verification/writes (HeadObject,
* GetObjectAttributes, .manifest.json PUT). In local mode this is the MinIO root user; in
* production a dedicated admin/broker identity with full bucket access (distinct
* from the narrowly-scoped session credentials handed to clients). */
export const adminS3Credentials = () => {
if (isLocalMode()) {
return minioRootCredentials();
}
return {
accessKeyId: requireEnv("BLOOM_S3_ADMIN_ACCESS_KEY"),
secretAccessKey: requireEnv("BLOOM_S3_ADMIN_SECRET_KEY"),
};
};
39 changes: 39 additions & 0 deletions supabase/functions/_shared/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Small JSON-response helpers shared by every edge function. Kept deliberately
// un-clever: the contract error shapes in CONTRACTS.md are just
// `{ error: "<Code>", ...extra }`, so we build exactly that.

export const CORS_HEADERS: Record<string, string> = {
// Bloom Desktop calls these from the C# process (HttpClient), not a browser page,
// so CORS is not actually load-bearing — but it's harmless and cheap to allow, in
// case any tooling (smoke tests, future browser-based admin UI) calls in directly.
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};

export const jsonResponse = (status: number, body: unknown): Response =>
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
});

/** Standard error envelope: `{ error: "<Code>", ...extra }`. */
export const errorResponse = (
status: number,
error: string,
extra?: Record<string, unknown>,
): Response => jsonResponse(status, { error, ...extra });

export class HttpError extends Error {
readonly status: number;
readonly body: Record<string, unknown>;
constructor(status: number, body: Record<string, unknown>) {
super(typeof body.error === "string" ? body.error : `HTTP ${status}`);
this.status = status;
this.body = body;
}
toResponse(): Response {
return jsonResponse(this.status, this.body);
}
}
58 changes: 58 additions & 0 deletions supabase/functions/_shared/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Common Deno.serve wrapper for every Cloud TC edge function: CORS preflight,
// method + JSON-body handling, and turning a thrown HttpError into the right JSON
// response. Keeps each function's index.ts focused on its own request shape.
import { CORS_HEADERS, errorResponse, HttpError } from "./errors.ts";

export type JsonHandler = (
req: Request,
body: Record<string, unknown>,
) => Promise<Response>;

/** Fails fast (throws HttpError 400) if `value` is missing/empty — used for the
* required fields in each function's request body. */
export const requireField = <T>(
body: Record<string, unknown>,
name: string,
): T => {
const value = body[name];
if (value === undefined || value === null || value === "") {
throw new HttpError(400, { error: "invalid_request", field: name });
}
return value as T;
};

export const optionalField = <T>(
body: Record<string, unknown>,
name: string,
): T | null => (body[name] as T | undefined) ?? null;

export const serveJsonPost = (handler: JsonHandler): void => {
Deno.serve(async (req: Request) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: CORS_HEADERS });
}
if (req.method !== "POST") {
return errorResponse(405, "method_not_allowed");
}

let body: Record<string, unknown>;
try {
const text = await req.text();
body = text ? JSON.parse(text) : {};
} catch {
return errorResponse(400, "invalid_json");
}

try {
return await handler(req, body);
} catch (err) {
if (err instanceof HttpError) {
return err.toResponse();
}
console.error("Unhandled error:", err);
return errorResponse(500, "internal_error", {
message: String(err),
});
}
});
};
Loading
Loading