Skip to content

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.

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>"
}
}
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
  1. Build the manifest without proof.
  2. Canonicalize it as JSON (RFC 8785 style): object keys sorted, no whitespace, members whose value is undefined left out.
  3. Sign the UTF-8 bytes of that string with your Ed25519 private key.
  4. Add proof with 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.

In this order, stopping at the first failure:

  1. The type and acre version are right.
  2. proof names a key listed in keys, and that key’s kid is its hash.
  3. The signature matches the canonical bytes.
  4. id and handle are the domain it was fetched from.
  5. The signing key is active and in date.
  6. The manifest is in date (clocks may differ by up to 5 minutes).
  7. 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.