Manifest reference
A self-hosted island describes itself in one JSON file at https://<your domain>/.well-known/acre-island.json, at most 64 KB. The acre island CLI writes and signs it for you (see Dock your own island). This page is for building or checking one by hand.
Example
Section titled “Example”The key id, key and signature below are placeholders. Use the signing script or the CLI to make real ones.
{ "type": "acre.selfhosted-island", "acre": "0.2", "id": "https://kestrel.example", "handle": "kestrel.example", "name": "Kestrel", "oneLiner": "Contract tools for small funds", "kind": "startup", "map": { "template": "autumn" }, "visit": "harbor", "contact": "mailto:hosts@kestrel.example", "realtime": { "url": "wss://kestrel.example/live" }, "keys": [ { "kid": "Qm9vX2tleV9pZF8x", "kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", "x": "<base64url of the 32-byte public key>", "status": "active", "createdAt": "2026-10-13T12:00:00.000Z", "expiresAt": "2027-10-13T12:00:00.000Z" } ], "issuedAt": "2026-10-13T12:00:00.000Z", "expiresAt": "2026-11-12T12:00:00.000Z", "proof": { "alg": "Ed25519", "kid": "https://kestrel.example/.well-known/acre-island.json#Qm9vX2tleV9pZF8x", "sig": "<base64url Ed25519 signature>" }}Fields
Section titled “Fields”| Field | Required | Rule |
|---|---|---|
type |
yes | Exactly "acre.selfhosted-island" |
acre |
yes | Protocol version 0.x. Currently "0.2". |
id |
yes | https://<domain>. Must be the address the file was fetched from. |
handle |
yes | <domain>, matching id |
name |
yes | Not empty. Shown cut to 60 characters, with control and bidirectional-override characters removed. |
oneLiner |
no | Shown cut to 140 characters |
kind |
no | startup, fund, project (default) or community |
map.template |
no | The biome: meadow (default), autumn, blossom, frost, dunes, volcanic, swamp, canyon, crystal or skygarden. A top-level template is also accepted. |
visit |
no | harbor, invite (default) or island. Only harbor admits visitors from Harbor today. An old knock value is read as invite. |
contact |
no | At most 200 characters. An https:// page or a mailto: address. |
realtime.url |
no | Where visitors’ live connection goes. wss://, at most 300 characters, on your domain or a subdomain of it, port 443, with no credentials, query or fragment. |
skin |
no | { "color": "#3a7", "logo": "https://…" }. color is a 3- or 6-digit hex colour, adjusted to sit within ACRE’s palette. logo is an https:// URL on your own domain, at most 300 characters, not an SVG. |
keys |
yes | Your signing keys (see below) |
issuedAt |
yes | ISO 8601 time the manifest was signed |
expiresAt |
yes | ISO 8601 time it expires. At most 90 days after issuedAt. |
proof |
yes | The signature (see below) |
You may add other fields, but they are signed along with everything else. Harbor cleans text fields for display only. The signature always covers the bytes as you published them.
Each entry in keys is an Ed25519 public key in JWK form:
| Field | Rule |
|---|---|
kty, crv, alg |
"OKP", "Ed25519", "EdDSA" |
x |
The raw 32-byte public key, base64url |
kid |
The first 16 characters of the base64url SHA-256 of the raw 32-byte public key |
status |
"active" or "retired" |
createdAt, expiresAt |
ISO times. A key may live at most 730 days. |
Keep retired keys listed after a rotation. That is how Harbor knows the new key is a rotation and not a takeover.
| Field | Rule |
|---|---|
alg |
"Ed25519" |
kid |
https://<domain>/.well-known/acre-island.json#<kid>, naming an active key in keys |
sig |
base64url Ed25519 signature |
Signing
Section titled “Signing”- Build the manifest without
proof. - Canonicalize it as JSON (RFC 8785 style): object keys sorted, no whitespace, members whose value is
undefinedleft out. - Sign the UTF-8 bytes of that string with your Ed25519 private key.
- Add
proofwith the base64url signature.
In Node.js (no packages needed):
import { generateKeyPairSync, createHash, sign } from 'node:crypto';
const canon = (v) => v === null || typeof v !== 'object' ? JSON.stringify(v) : Array.isArray(v) ? '[' + v.map(canon).join(',') + ']' : '{' + Object.keys(v).filter((k) => v[k] !== undefined).sort() .map((k) => JSON.stringify(k) + ':' + canon(v[k])).join(',') + '}';
// Make a key once and keep the private key safe (the CLI stores it as acre-island.key).const { privateKey, publicKey } = generateKeyPairSync('ed25519');const x = publicKey.export({ format: 'jwk' }).x;const kid = createHash('sha256').update(Buffer.from(x, 'base64url')).digest('base64url').slice(0, 16);
const origin = 'https://kestrel.example';const DAY = 86400000, now = Date.now();const doc = { type: 'acre.selfhosted-island', acre: '0.2', id: origin, handle: 'kestrel.example', name: 'Kestrel', oneLiner: 'Contract tools for small funds', kind: 'startup', map: { template: 'autumn' }, visit: 'harbor', contact: 'mailto:hosts@kestrel.example', realtime: { url: 'wss://kestrel.example/live' }, keys: [{ kid, kty: 'OKP', crv: 'Ed25519', alg: 'EdDSA', x, status: 'active', createdAt: new Date(now).toISOString(), expiresAt: new Date(now + 365 * DAY).toISOString() }], issuedAt: new Date(now).toISOString(), expiresAt: new Date(now + 30 * DAY).toISOString(),};doc.proof = { alg: 'Ed25519', kid: `${origin}/.well-known/acre-island.json#${kid}`, sig: sign(null, Buffer.from(canon(doc)), privateKey).toString('base64url'),};console.log(JSON.stringify(doc, null, 2));Then check it with acre island verify kestrel.example.
How Harbor checks it
Section titled “How Harbor checks it”In this order, stopping at the first failure:
- The
typeandacreversion are right. proofnames a key listed inkeys, and that key’skidis its hash.- The signature matches the canonical bytes.
idandhandleare the domain it was fetched from.- The signing key is
activeand in date. - The manifest is in date (clocks may differ by up to 5 minutes).
- Every field is valid.
When re-reading an island it already lists, Harbor also checks that the key it pinned is still listed, as active or retired.