ichibaseichibase

Storage

Store files in object storage. There are two access modes: public files served anonymously from a CDN, and private files reachable only with a short-lived token that you — the project owner — mint server-side. Clients never mint tokens; that keeps private files your responsibility.

The client SDK has no storage module, and the token/upload-URL endpoints reject the publishable (anon) key. Minting a signed URL requires the service key (ich_admin_), which only your server / Edge Functions hold. This is by design — a leaked anon key can't sign for or enumerate private files.

Public files

Anything in the reserved public bucket is served anonymously from the CDN — no token. Put the URL straight into an <img> tag:

https://cdn.ichibase.net/<project>/public/<path>

Make paths unguessable (UUIDs) if you want obscurity. Reads are CDN-cached and metered.

Private files: you mint the tokens

For everything else, the flow is: your client asks your backend (an Edge Function) for access → your function checks who the user is and what they may touch → it mints a token scoped to a single file or a whole folder, with an expiry → it returns the signed URL to the client. The token is reusable until it expires.

Deploy this as an Edge Function. It runs with your service key injected as env:

// Edge Function: "files" — your storage gateway. Runs server-side with the
// service key, so it (and only it) can mint tokens.
const BASE = Deno.env.get("ICHIBASE_PROJECT_URL")!;
const SERVICE = Deno.env.get("ICHIBASE_SERVICE_KEY")!; // ich_admin_… (never leaves the server)

Deno.serve(async (req) => {
  // 1. Authenticate the caller however you like (their JWT, a session, etc.)
  const userId = req.headers.get("x-user-id"); // example
  if (!userId) return new Response("unauthorized", { status: 401 });

  const { bucket, path } = await req.json();

  // 2. Decide what this user may reach. Here: only their own folder.
  if (!path.startsWith(`users/${userId}/`)) {
    return new Response("forbidden", { status: 403 });
  }

  // 3. Mint a READ token (service key required). Single file:
  const res = await fetch(`${BASE}/storage/get-url`, {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${SERVICE}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ bucket, path, ttl_seconds: 300 }),
  });
  const signed = await res.json(); // { url, token, exp }
  return Response.json(signed);
});

Folder (prefix) tokens

Pass recursive: true with a folder path and the token authorizes everything under that folder until it expires. The response is a base URL + a token; the client builds ${base}/<file>?token=${token} for each file.

// inside your Edge Function — a token good for the whole folder for 1 hour
const res = await fetch(`${BASE}/storage/get-url`, {
  method: "POST",
  headers: { "Authorization": `Bearer ${SERVICE}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    bucket: "invoices",
    path: `users/${userId}/`,   // a folder
    recursive: true,
    ttl_seconds: 3600,
  }),
});
const { base, token } = await res.json();
// client reads any file under it:  `${base}/march.pdf?token=${token}`

Uploads — you sign the PUT URL

Same pattern for writes: your function decides if the user may upload, then signs a one-time PUT URL and returns it. The client PUTs the bytes straight to storage; your server never proxies the file.

// inside your Edge Function — sign an upload URL
const res = await fetch(`${BASE}/storage/get-put-url`, {
  method: "POST",
  headers: { "Authorization": `Bearer ${SERVICE}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    bucket: "avatars",
    path: `users/${userId}/avatar.png`,
    content_type: "image/png",
    content_length: 102400,
  }),
});
const { url } = await res.json();
return Response.json({ url }); // client does: fetch(url, { method: "PUT", body: file })

Deletes work the same way — your function calls /storage/deletewith the service key after authorizing the request. One “files” function can route GET / PUT / DELETE and hold all your token logic in one place.

On the client

The client just calls your function and uses what it gets back — no storage SDK, no keys beyond the anon key it already has. A private read is the same URL as a public one, plus a ?token= query parameter carrying the JWT you minted:

https://cdn.ichibase.net/<project>/<bucket>/<path>?token=<jwt>

For a single file, /storage/get-url returns a ready-built url with the ?token= already appended — use it as-is. For a folder (prefix) token you get a base + token back and append the ?token= parameter yourself for each file:

// ask your Edge Function for access, then read with the token as a query param.
// invoke() attaches the signed-in user's token automatically.
const { data } = await ichi.functions.invoke('files', {
  body: { bucket: 'invoices', path: `users/${myUserId}/march.pdf` },
});

// single file → data.url already ends with ?token=…
const file = await fetch(data.url);

// folder (prefix) token → build the URL with the token parameter yourself
// const { base, token } = data;
// const file = await fetch(`${base}/march.pdf?token=${token}`);
Why this design: signed URLs and reusable tokens bypass per-request auth, so the only safe place to decide “who can read/write what” is your own code. Keeping minting server-side (service key) means your access rules — not a shipped key — are the boundary. See Edge Functions and API keys & security.