agents can manage the calendar and the address book
the owner's scenario: any chat, "I'm having dinner Saturday with my wife, add that", "cancel it", "add Peter Griffin, this number" — and it happens. reading already worked through the JSON api. writing is the DAV door, and the credential question is what took the thinking: every option meant a NEW secret and then somewhere to keep it — a file, the secret store, one per commander — each a password to distribute, rotate and eventually leak. $OFFICER_AGENT_KEY is already in every agent's environment, already bound to one user, already revoked when the session ends. so the DAV door accepts it as the basic-auth password, and no new secret exists anywhere. it cannot reach another account, and that is structural rather than a check: resolveApiKey says whose the key is and that id becomes X-Remote-User. the URL is never consulted, radicale refuses any other principal, and the username in the header must AGREE with the key rather than select the account. verified — own principal 207/201, another account 403, spoofed username 401. the permission check is isApiRequestAllowed against this plugin's own prefix, so the answer is exactly what the gate would give the same credential at /api/caldav. a second copy of that rule is the copy that stops being updated. DAV methods are mapped to read/write with PROPFIND and REPORT as reads — unmapped they counted as writes and a member with read could not list their own calendar. anything unrecognised defaults to write, which is the safe direction. the skill is written from recipes that were RUN, not recalled: create 201, move 204 (same UID, same href, SEQUENCE bumped), delete 200, contact create and delete, and the json read for lookups. the trap it exists to prevent, found while testing rather than by reading: `date -u -d 'saturday 20:00'` does not convert. -u parses AND prints as UTC, so 8pm becomes 20:00Z, which is 21:00 in lisbon. every event an hour out, nothing errors, and it is invisible until someone is stood up. the two-step through epoch is the only form that works and the skill leads with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
+36
-3
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Context } from 'hono';
|
||||||
import { createRouter } from '@@/create-router';
|
import { createRouter } from '@@/create-router';
|
||||||
import {
|
import {
|
||||||
listDavAppPasswords,
|
listDavAppPasswords,
|
||||||
@@ -18,6 +19,35 @@ import { stashIosProfile } from './ios-profile';
|
|||||||
|
|
||||||
export const appPasswordsRouter = createRouter();
|
export const appPasswordsRouter = createRouter();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read this request's JSON body.
|
||||||
|
*
|
||||||
|
* `ctx.get('body')` is the platform's parsed body, and it is EMPTY here — deliberately, and for a reason
|
||||||
|
* that only became true when caldav became a plugin.
|
||||||
|
*
|
||||||
|
* `createSidecarProxy` registers `/api/caldav` with `registerProxiedPrefix`, which tells `bodyParser` to
|
||||||
|
* leave those request streams untouched. That is right for forwarded traffic and load-bearing for
|
||||||
|
* multipart uploads: a body parsed here is re-serialised with a new boundary while the old content-type
|
||||||
|
* header is still forwarded, and every upload fails. But the prefix covers this plugin's OWN routes too,
|
||||||
|
* so they get `{}` and every field reads as missing.
|
||||||
|
*
|
||||||
|
* Before the extraction these routes lived under `/api/dav`, a second top-level prefix that was not
|
||||||
|
* proxied, so their bodies parsed. One prefix per plugin is the rule that ended that arrangement.
|
||||||
|
*
|
||||||
|
* Read here rather than fixed in the platform because the platform's rule is correct — the narrower fix
|
||||||
|
* is for the proxy to register only the sub-path it actually serves (`_officer/*`), which is a change to
|
||||||
|
* shared machinery every sidecar depends on and not one to make while a phone is waiting.
|
||||||
|
*/
|
||||||
|
async function jsonBody<T>(ctx: Context): Promise<T> {
|
||||||
|
try {
|
||||||
|
return (await ctx.req.json()) as T;
|
||||||
|
} catch {
|
||||||
|
// No body, or not JSON. Every caller below treats a missing field as a bad request anyway.
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
appPasswordsRouter.get('/passwords', async (ctx) => {
|
appPasswordsRouter.get('/passwords', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
return ctx.json({ passwords: await listDavAppPasswords(user.id) });
|
return ctx.json({ passwords: await listDavAppPasswords(user.id) });
|
||||||
@@ -25,7 +55,7 @@ appPasswordsRouter.get('/passwords', async (ctx) => {
|
|||||||
|
|
||||||
appPasswordsRouter.post('/passwords', async (ctx) => {
|
appPasswordsRouter.post('/passwords', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
const body = ctx.get('body') as { label?: string } | undefined;
|
const body = await jsonBody<{ label?: string }>(ctx);
|
||||||
const label = body?.label?.trim();
|
const label = body?.label?.trim();
|
||||||
if (!label) throw errors.BAD_REQUEST('label is required');
|
if (!label) throw errors.BAD_REQUEST('label is required');
|
||||||
|
|
||||||
@@ -42,7 +72,7 @@ appPasswordsRouter.post('/passwords', async (ctx) => {
|
|||||||
// exists because the alternative is the owner typing a 25-character secret into a phone keyboard twice.
|
// exists because the alternative is the owner typing a 25-character secret into a phone keyboard twice.
|
||||||
appPasswordsRouter.post('/provision/ios', async (ctx) => {
|
appPasswordsRouter.post('/provision/ios', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
const body = ctx.get('body') as { deviceLabel?: string } | undefined;
|
const body = await jsonBody<{ deviceLabel?: string; only?: 'caldav' | 'carddav' }>(ctx);
|
||||||
const deviceLabel = body?.deviceLabel?.trim();
|
const deviceLabel = body?.deviceLabel?.trim();
|
||||||
if (!deviceLabel) throw errors.BAD_REQUEST('deviceLabel is required');
|
if (!deviceLabel) throw errors.BAD_REQUEST('deviceLabel is required');
|
||||||
|
|
||||||
@@ -74,7 +104,10 @@ appPasswordsRouter.post('/provision/ios', async (ctx) => {
|
|||||||
// The principal, not a collection: iOS discovers every calendar and address book under it, so a
|
// The principal, not a collection: iOS discovers every calendar and address book under it, so a
|
||||||
// collection the owner adds later appears without re-provisioning.
|
// collection the owner adds later appears without re-provisioning.
|
||||||
principalUrl: `/dav/${user.id}/`,
|
principalUrl: `/dav/${user.id}/`,
|
||||||
accountName: 'Officer',
|
accountName: body.only === 'carddav' ? 'Officer Contacts' : 'Officer',
|
||||||
|
// `only` splits the two accounts across two profiles — see ProfileParams.kinds for the iOS 26
|
||||||
|
// behaviour that made it necessary.
|
||||||
|
...(body.only ? { kinds: [body.only === 'carddav' ? ('CardDAV' as const) : ('CalDAV' as const)] } : {}),
|
||||||
},
|
},
|
||||||
url.origin,
|
url.origin,
|
||||||
);
|
);
|
||||||
|
|||||||
+46
-7
@@ -16,7 +16,19 @@ import { randomUUID, randomBytes } from 'node:crypto';
|
|||||||
//
|
//
|
||||||
// See docs/mobile-dav-provisioning.md §3 for the payload keys and where they come from.
|
// See docs/mobile-dav-provisioning.md §3 for the payload keys and where they come from.
|
||||||
|
|
||||||
const TTL_MS = 5 * 60_000;
|
/*
|
||||||
|
How long a provisioning link lives.
|
||||||
|
|
||||||
|
Five minutes until 2026-08-27, which assumed the person minting the link is the person clicking it —
|
||||||
|
true for a button in the UI, false for every other way this gets used. Handing a URL to someone who then
|
||||||
|
has to pick up a phone, open Safari and type it took longer than that every single time, and the failure
|
||||||
|
is indistinguishable from a broken profile: the link 404s and iOS says "Invalid Profile", which sends you
|
||||||
|
looking at the plist rather than at the clock. That cost most of an evening.
|
||||||
|
|
||||||
|
Thirty minutes, still single-use. The link embeds a live password, so this is a real trade — but the
|
||||||
|
binding constraint is that it is claimed ONCE and then gone, not that the window is narrow.
|
||||||
|
*/
|
||||||
|
const TTL_MS = 30 * 60_000;
|
||||||
// A restart clears everything anyway; this is only a guard against a script hammering the mint endpoint.
|
// A restart clears everything anyway; this is only a guard against a script hammering the mint endpoint.
|
||||||
const MAX_PENDING = 32;
|
const MAX_PENDING = 32;
|
||||||
|
|
||||||
@@ -41,6 +53,21 @@ type ProfileParams = {
|
|||||||
principalUrl: string;
|
principalUrl: string;
|
||||||
/** What the user sees as the account name in Settings. */
|
/** What the user sees as the account name in Settings. */
|
||||||
accountName: string;
|
accountName: string;
|
||||||
|
/**
|
||||||
|
* Which accounts this profile creates. Both, unless something is wrong.
|
||||||
|
*
|
||||||
|
* ── Why this had to exist ──
|
||||||
|
*
|
||||||
|
* The comment below says one profile carrying both payloads sidesteps iOS folding CardDAV into an
|
||||||
|
* existing CalDAV account with the same server and username. On iOS 26.6 it does not: installing the
|
||||||
|
* two-payload profile produced ONE account offering Calendars and Reminders, with no Contacts toggle
|
||||||
|
* anywhere and 460 cards that the phone never once requested. Verified from the server side — the
|
||||||
|
* principal advertises `addressbook-home-set`, `/1/contacts/` is typed as an addressbook, and nothing
|
||||||
|
* had ever asked for it.
|
||||||
|
*
|
||||||
|
* So CardDAV gets its own profile, with its own PayloadIdentifier, which iOS has nothing to merge into.
|
||||||
|
*/
|
||||||
|
kinds?: ('CalDAV' | 'CardDAV')[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// One profile, both payloads. This is not a convenience: iOS keys accounts by server+username, so adding
|
// One profile, both payloads. This is not a convenience: iOS keys accounts by server+username, so adding
|
||||||
@@ -48,10 +75,11 @@ type ProfileParams = {
|
|||||||
// silently never appear. A single profile creates both accounts outright and sidesteps it. Detail in
|
// silently never appear. A single profile creates both accounts outright and sidesteps it. Detail in
|
||||||
// docs/mobile-dav-provisioning.md §1.6, which cost an evening to learn.
|
// docs/mobile-dav-provisioning.md §1.6, which cost an evening to learn.
|
||||||
function buildPlist(params: ProfileParams): string {
|
function buildPlist(params: ProfileParams): string {
|
||||||
const { host, port, username, password, principalUrl, accountName } = params;
|
const { host, port, username, password, principalUrl, accountName, kinds = ['CalDAV', 'CardDAV'] } = params;
|
||||||
// Stable per host, so re-running setup REPLACES the profile instead of stacking a second account, and
|
// Stable per host, so re-running setup REPLACES the profile instead of stacking a second account, and
|
||||||
// two Officer instances on one phone do not overwrite each other.
|
// two Officer instances on one phone do not overwrite each other.
|
||||||
const identifier = `dev.officer.dav.${host.replace(/[^a-zA-Z0-9]+/g, '-')}`;
|
const suffix = kinds.length === 1 ? `.${kinds[0]!.toLowerCase()}` : '';
|
||||||
|
const identifier = `dev.officer.dav.${host.replace(/[^a-zA-Z0-9]+/g, '-')}${suffix}`;
|
||||||
const e = escapeXml;
|
const e = escapeXml;
|
||||||
|
|
||||||
const account = (kind: 'CalDAV' | 'CardDAV', label: string) => ` <dict>
|
const account = (kind: 'CalDAV' | 'CardDAV', label: string) => ` <dict>
|
||||||
@@ -80,12 +108,12 @@ function buildPlist(params: ProfileParams): string {
|
|||||||
<key>PayloadDisplayName</key><string>${e(accountName)} — Calendar & Contacts</string>
|
<key>PayloadDisplayName</key><string>${e(accountName)} — Calendar & Contacts</string>
|
||||||
<key>PayloadDescription</key><string>Adds your Officer calendars and address book to this device.</string>
|
<key>PayloadDescription</key><string>Adds your Officer calendars and address book to this device.</string>
|
||||||
<key>PayloadOrganization</key><string>Officer</string>
|
<key>PayloadOrganization</key><string>Officer</string>
|
||||||
<key>PayloadScope</key><string>User</string>
|
<!-- PayloadScope is deliberately absent: Apple documents it for macOS only, and iOS rejected this
|
||||||
|
profile outright with "Invalid Profile" while it was present. Removed 2026-08-27. -->
|
||||||
<key>PayloadRemovalDisallowed</key><false/>
|
<key>PayloadRemovalDisallowed</key><false/>
|
||||||
<key>PayloadContent</key>
|
<key>PayloadContent</key>
|
||||||
<array>
|
<array>
|
||||||
${account('CalDAV', 'Officer Calendar')}
|
${kinds.map((k) => account(k, k === 'CalDAV' ? 'Officer Calendar' : 'Officer Contacts')).join('\n')}
|
||||||
${account('CardDAV', 'Officer Contacts')}
|
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -183,7 +211,18 @@ export async function stashIosProfile(params: ProfileParams, publicUrl: string):
|
|||||||
export function claimIosProfile(token: string): Uint8Array | null {
|
export function claimIosProfile(token: string): Uint8Array | null {
|
||||||
sweep();
|
sweep();
|
||||||
const entry = pending.get(token);
|
const entry = pending.get(token);
|
||||||
if (!entry) return null;
|
if (!entry) {
|
||||||
|
// SAID OUT LOUD, because the alternative is silence at the only moment it matters.
|
||||||
|
//
|
||||||
|
// A miss means expired, already used, or never existed, and the caller cannot be told which — telling
|
||||||
|
// them would confirm a token once existed. But WE need to know: the response is a 404, iOS renders
|
||||||
|
// that as "Invalid Profile", and the person reading it goes and inspects the plist. Nothing in the log
|
||||||
|
// said a phone had even asked. One line here is the difference between "the link expired" and an
|
||||||
|
// evening spent validating XML that was correct the whole time.
|
||||||
|
console.warn(`[dav] ios profile claim MISSED (expired, already used, or never minted) — ${pending.size} pending`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
pending.delete(token);
|
pending.delete(token);
|
||||||
|
console.log(`[dav] ios profile claimed — ${pending.size} still pending`);
|
||||||
return entry.body;
|
return entry.body;
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-1
@@ -27,10 +27,22 @@ import { claimIosProfile } from './ios-profile';
|
|||||||
|
|
||||||
export const router = createRouter();
|
export const router = createRouter();
|
||||||
|
|
||||||
|
/*
|
||||||
|
ORDER IS LOAD-BEARING, and getting it wrong is silent.
|
||||||
|
|
||||||
|
`caldavRouter` is a sidecar proxy, and `createSidecarProxy` mounts `router.all('/*')` — a catch-all. Hono
|
||||||
|
matches in registration order, so mounting it first means it swallows EVERY path under `/api/caldav`,
|
||||||
|
forwards it to the sidecar, and the sidecar answers 404 for anything that is not its own `_officer/*`
|
||||||
|
namespace. `POST /api/caldav/provision/ios` returned "not found" for exactly that reason, and it reads
|
||||||
|
like a missing route rather than a shadowed one.
|
||||||
|
|
||||||
|
So: the plugin's own routes first, the catch-all proxy last.
|
||||||
|
*/
|
||||||
|
router.route('/', appPasswordsRouter);
|
||||||
|
|
||||||
// The JSON door for this plugin's own screens. `_officer/*` is the sidecar's namespace; everything under
|
// The JSON door for this plugin's own screens. `_officer/*` is the sidecar's namespace; everything under
|
||||||
// it is proxied to the Radicale-holding sidecar, which is the only thing that ever sees a DAV credential.
|
// it is proxied to the Radicale-holding sidecar, which is the only thing that ever sees a DAV credential.
|
||||||
router.route('/', caldavRouter);
|
router.route('/', caldavRouter);
|
||||||
router.route('/', appPasswordsRouter);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mounted at EVERY path the manifest claims — `/dav`, `/.well-known/caldav`, `/.well-known/carddav` — so
|
* Mounted at EVERY path the manifest claims — `/dav`, `/.well-known/caldav`, `/.well-known/carddav` — so
|
||||||
|
|||||||
+72
-2
@@ -1,4 +1,9 @@
|
|||||||
|
import type { Context } from 'hono';
|
||||||
import { createRouter } from '@@/create-router';
|
import { createRouter } from '@@/create-router';
|
||||||
|
import { looksLikeApiKey, resolveApiKey } from '@@/auth-token';
|
||||||
|
import { isApiRequestAllowed } from '@@/permissions/authorize';
|
||||||
|
import { mountPrefix } from '@@/plugins/manifest';
|
||||||
|
import { manifest } from '../manifest';
|
||||||
import { getUserByEmail } from 'officerdb';
|
import { getUserByEmail } from 'officerdb';
|
||||||
import { verifyDavAppPassword } from '../db/queries';
|
import { verifyDavAppPassword } from '../db/queries';
|
||||||
import { getCaldavUrl } from './sidecar-server';
|
import { getCaldavUrl } from './sidecar-server';
|
||||||
@@ -76,6 +81,71 @@ const RESPONSE_HEADERS = [
|
|||||||
'vary',
|
'vary',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
DAV methods, sorted into read and write.
|
||||||
|
|
||||||
|
`decideApiRequest` reasons in HTTP terms — a safe method needs a read grant, anything else needs write —
|
||||||
|
and DAV's vocabulary is not in that list at all. Left unmapped, PROPFIND and REPORT would count as
|
||||||
|
writes, and a member granted READ on the calendar could not so much as list it.
|
||||||
|
|
||||||
|
Anything not named here is treated as a WRITE. That is the safe direction for a default: a DAV extension
|
||||||
|
nobody here has heard of asking for read access is a small annoyance, and one silently granted write
|
||||||
|
access is not.
|
||||||
|
*/
|
||||||
|
const DAV_READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'PROPFIND', 'REPORT']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The other way to authenticate here: an agent's own session credential.
|
||||||
|
*
|
||||||
|
* ── Why this exists ──
|
||||||
|
*
|
||||||
|
* The owner asked for any agent they are chatting with to be able to add an event or a contact. Doing
|
||||||
|
* that through this door needs a credential, and every alternative meant creating a NEW secret and then
|
||||||
|
* solving where it lives — a file on disk, the secret store, one per commander. Each of those is a
|
||||||
|
* password to distribute, rotate and eventually leak.
|
||||||
|
*
|
||||||
|
* `$OFFICER_AGENT_KEY` is already in every agent's environment, already bound to one user, already
|
||||||
|
* revoked when the session ends. Accepting it here adds no secret to the world.
|
||||||
|
*
|
||||||
|
* ── Why it cannot reach another account ──
|
||||||
|
*
|
||||||
|
* The key IS the identity — `resolveApiKey` returns whose it is, and that id becomes `X-Remote-User`.
|
||||||
|
* There is no field an agent could change to address someone else: the URL is not consulted, and Radicale
|
||||||
|
* refuses a principal that is not the one in the header. The username in the Basic header must also match
|
||||||
|
* the key's owner, so a request cannot even LOOK like it is acting for somebody else.
|
||||||
|
*
|
||||||
|
* ── Why the permission check is the platform's own ──
|
||||||
|
*
|
||||||
|
* `isApiRequestAllowed` against this plugin's API prefix, so the answer is exactly what the permission
|
||||||
|
* gate would give for the same credential on the same plugin: role grants, the credential's own allow
|
||||||
|
* list, and its excluded kinds. Reimplementing that here would be a second copy of the rule, and the
|
||||||
|
* second copy is the one that stops being updated. A key narrowed away from `calendar` is refused here
|
||||||
|
* for the same reason it would be refused at `/api/caldav`.
|
||||||
|
*/
|
||||||
|
async function agentKeyUser(ctx: Context, user: { id: number }, secret: string): Promise<number | null> {
|
||||||
|
if (!looksLikeApiKey(secret)) return null;
|
||||||
|
|
||||||
|
const identity = await resolveApiKey(secret);
|
||||||
|
if (!identity) return null;
|
||||||
|
|
||||||
|
// The credential decides who this is; the username has to agree with it rather than select it.
|
||||||
|
if (identity.id !== user.id) {
|
||||||
|
console.warn(`[dav] auth failed: api key belongs to user ${identity.id}, not ${user.id}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = DAV_READ_METHODS.has(ctx.req.method.toUpperCase()) ? 'GET' : 'PUT';
|
||||||
|
const verdict = await isApiRequestAllowed(identity.id, method, `/api${mountPrefix({ appName: 'caldav', manifest })}/`, identity.scope);
|
||||||
|
if (!verdict.allowed) {
|
||||||
|
console.warn(`[dav] api key refused for user ${identity.id}: ${verdict.reason ?? 'not permitted'}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[dav] authenticated user ${identity.id} by session credential (${ctx.req.method})`);
|
||||||
|
return identity.id;
|
||||||
|
}
|
||||||
|
|
||||||
davSyncRouter.all('/*', async (ctx) => {
|
davSyncRouter.all('/*', async (ctx) => {
|
||||||
const creds = parseBasic(ctx.req.header('authorization'));
|
const creds = parseBasic(ctx.req.header('authorization'));
|
||||||
if (!creds) return unauthorized();
|
if (!creds) return unauthorized();
|
||||||
@@ -89,9 +159,9 @@ davSyncRouter.all('/*', async (ctx) => {
|
|||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = await verifyDavAppPassword(user.id, creds.password);
|
const userId = (await verifyDavAppPassword(user.id, creds.password)) ?? (await agentKeyUser(ctx, user, creds.password));
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
console.warn(`[dav] auth failed: bad app password for ${creds.username}`);
|
console.warn(`[dav] auth failed: bad credential for ${creds.username}`);
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
---
|
||||||
|
name: Calendar and contacts
|
||||||
|
description: Use when the owner asks you to add, move, cancel or look up an event, or to add, remove or find a contact — "dinner Saturday with my wife", "cancel that", "when am I seeing the CEO", "add Peter Griffin, this number". Carries the working recipes, the two credentials, and the timezone trap that puts every event an hour out.
|
||||||
|
version: 1
|
||||||
|
---
|
||||||
|
|
||||||
|
You can read and write the owner's calendars and address book directly. **Reading goes through Officer's
|
||||||
|
API. Writing goes through the DAV door.** Both use credentials you already have; there is nothing to ask
|
||||||
|
for and no password to be given.
|
||||||
|
|
||||||
|
## The two doors
|
||||||
|
|
||||||
|
**Read — Officer's own API, with `$OFFICER_AGENT_KEY`.** Parsed JSON, no XML.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \
|
||||||
|
"$OFFICER_API_URL/api/caldav/_officer/collections"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Write — the DAV door, HTTP Basic, the SAME key as the password.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ME=$(curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \
|
||||||
|
"$OFFICER_API_URL/api/auth/me" | python3 -c 'import json,sys; print(json.load(sys.stdin)["email"])')
|
||||||
|
AUTH="$ME:$OFFICER_AGENT_KEY"
|
||||||
|
```
|
||||||
|
|
||||||
|
Your key is bound to ONE account — the owner's. You cannot reach anyone else's calendar with it and you
|
||||||
|
do not need to guard against doing so: another principal answers 403, and naming a different user in the
|
||||||
|
Basic header answers 401. Write the owner's own paths and stop thinking about it.
|
||||||
|
|
||||||
|
## Find the collections first — never guess a path
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \
|
||||||
|
"$OFFICER_API_URL/api/caldav/_officer/collections"
|
||||||
|
# {"collections":[{"path":"/dav/1/personal/","displayName":"Personal","kind":"calendar", ...},
|
||||||
|
# {"path":"/dav/1/contacts/","displayName":"Contacts","kind":"addressbook"}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
`kind` is `calendar` or `addressbook`. There are usually several calendars — **"Personal" is the default
|
||||||
|
for anything the owner does not place**, and Tasks holds VTODOs that show up in Reminders on a phone.
|
||||||
|
|
||||||
|
## THE TIMEZONE TRAP — read this before writing any time
|
||||||
|
|
||||||
|
The owner says "Saturday at 8". Calendars store UTC. **`date -u -d` does not convert** — `-u` makes GNU
|
||||||
|
date parse the input as UTC *and* print UTC, so "8pm" becomes 20:00Z, which is 21:00 in Lisbon. Every
|
||||||
|
event lands an hour out and nothing errors.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# WRONG — prints 20260829T200000Z
|
||||||
|
date -u -d 'saturday 20:00' +%Y%m%dT%H%M%SZ
|
||||||
|
# RIGHT — prints 20260829T190000Z, via epoch so the local zone is applied
|
||||||
|
date -u -d "@$(date -d 'saturday 20:00' +%s)" +%Y%m%dT%H%M%SZ
|
||||||
|
```
|
||||||
|
|
||||||
|
The machine's clock is the owner's timezone. Parse what they said with plain `date -d`, convert through
|
||||||
|
`+%s`, and **say the LOCAL time back to them** when you confirm — never the UTC you stored.
|
||||||
|
|
||||||
|
## Create an event
|
||||||
|
|
||||||
|
The filename must be `<UID>.ics`, and iCalendar lines are **CRLF-terminated**. `DTSTAMP` is required.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
UID="officer-$(date +%s)-$RANDOM"
|
||||||
|
S=$(date -u -d "@$(date -d 'saturday 20:00' +%s)" +%Y%m%dT%H%M%SZ)
|
||||||
|
E=$(date -u -d "@$(date -d 'saturday 22:00' +%s)" +%Y%m%dT%H%M%SZ)
|
||||||
|
printf "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Officer//agent//EN\r\nBEGIN:VEVENT\r\nUID:$UID\r\nDTSTAMP:$(date -u +%Y%m%dT%H%M%SZ)\r\nDTSTART:$S\r\nDTEND:$E\r\nSUMMARY:Dinner with my wife\r\nLOCATION:Cervejaria Ramiro\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" \
|
||||||
|
| curl -s -o /dev/null -w '%{http_code}\n' -u "$AUTH" -X PUT \
|
||||||
|
-H 'Content-Type: text/calendar; charset=utf-8' --data-binary @- \
|
||||||
|
"$OFFICER_API_URL/dav/1/personal/$UID.ics"
|
||||||
|
# 201 created
|
||||||
|
```
|
||||||
|
|
||||||
|
An **all-day** event uses dates, not times, and DTEND is the day AFTER:
|
||||||
|
`DTSTART;VALUE=DATE:20260829` with `DTEND;VALUE=DATE:20260830`.
|
||||||
|
|
||||||
|
## Look something up
|
||||||
|
|
||||||
|
"When am I having dinner with the CEO?" — read, filter, answer in local time.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \
|
||||||
|
"$OFFICER_API_URL/api/caldav/_officer/events?collection=%2Fdav%2F1%2Fpersonal%2F"
|
||||||
|
# {"events":[{"uid":"…","summary":"Dinner with my wife","start":"2026-08-29T19:00:00Z","end":"…",
|
||||||
|
# "location":"Cervejaria Ramiro","allDay":false}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
`start` and `end` are UTC. **Convert before speaking**: `date -d '2026-08-29T19:00:00Z' '+%A %-d %B, %H:%M'`.
|
||||||
|
|
||||||
|
Search every calendar, not just Personal — the answer is often in another one. `location` is where
|
||||||
|
"where did I set it up" is answered from; if it is empty, say so rather than inventing somewhere.
|
||||||
|
|
||||||
|
`rrule` present means the event REPEATS and the listed `start` is the series start, not the next
|
||||||
|
occurrence. Do not compute occurrences yourself — say it repeats and give the rule in words.
|
||||||
|
|
||||||
|
## Move an event
|
||||||
|
|
||||||
|
**Same UID, same filename, new times.** A new UID creates a second event and leaves the first in place,
|
||||||
|
which is the most common way this goes wrong.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# fetch the current copy, so nothing else in it is lost
|
||||||
|
curl -s -u "$AUTH" "$OFFICER_API_URL/dav/1/personal/$UID.ics" > /tmp/ev.ics
|
||||||
|
# then PUT a version with new DTSTART/DTEND and SEQUENCE bumped by one
|
||||||
|
```
|
||||||
|
|
||||||
|
Bump `SEQUENCE` (add `SEQUENCE:1`, or increment what is there). Phones use it to tell an update from a
|
||||||
|
duplicate. A successful update answers **204**, not 201 — 201 means you created something new, so check
|
||||||
|
your filename.
|
||||||
|
|
||||||
|
## Delete an event, or a contact
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' -u "$AUTH" -X DELETE "$OFFICER_API_URL/dav/1/personal/$UID.ics"
|
||||||
|
# 200
|
||||||
|
```
|
||||||
|
|
||||||
|
**Find the UID by reading first — never guess.** The file is normally `<UID>.ics`, and if that 404s, list
|
||||||
|
the collection and match on href:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -u "$AUTH" -X PROPFIND -H 'Depth: 1' "$OFFICER_API_URL/dav/1/personal/" | grep -o '<D:href>[^<]*'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Confirm before deleting when the match is not exact.** "Cancel dinner on Saturday" with one dinner on
|
||||||
|
Saturday is unambiguous — do it. Two dinners, or a recurring one, is a question: deleting the wrong event
|
||||||
|
cannot be undone from here, and the owner will discover it when someone is stood up.
|
||||||
|
|
||||||
|
Deleting one occurrence of a REPEATING event is not a DELETE — it needs an EXDATE added to the series.
|
||||||
|
If that is what they meant, say so rather than removing every occurrence.
|
||||||
|
|
||||||
|
## Add or remove a contact
|
||||||
|
|
||||||
|
Same shape, `.vcf` in the addressbook collection.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
UID="officer-$(date +%s)-$RANDOM"
|
||||||
|
printf "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:$UID\r\nFN:Peter Griffin\r\nN:Griffin;Peter;;;\r\nTEL;TYPE=CELL:+351912345678\r\nEMAIL;TYPE=INTERNET:peter@quahog.example\r\nEND:VCARD\r\n" \
|
||||||
|
| curl -s -o /dev/null -w '%{http_code}\n' -u "$AUTH" -X PUT \
|
||||||
|
-H 'Content-Type: text/vcard; charset=utf-8' --data-binary @- \
|
||||||
|
"$OFFICER_API_URL/dav/1/contacts/$UID.vcf"
|
||||||
|
```
|
||||||
|
|
||||||
|
`FN` is what shows on a phone and is REQUIRED. `N` is `Family;Given;Middle;Prefix;Suffix` — fill what you
|
||||||
|
know and leave the rest empty rather than omitting the line. Read contacts back with
|
||||||
|
`_officer/contacts?collection=…`, which returns `fullName`, `emails` and `phones` already parsed.
|
||||||
|
|
||||||
|
## Confirm in one line, in their words
|
||||||
|
|
||||||
|
"Added — Dinner with my wife, Saturday 29 August, 20:00, Cervejaria Ramiro." Local time, the name they
|
||||||
|
used, and where. Not the UID, not the UTC, not the HTTP status.
|
||||||
|
|
||||||
|
If a write fails, say what failed and stop. **Never retry a PUT with a new UID** — the usual cause of a
|
||||||
|
failure is that the first one partly worked, and a retry leaves two.
|
||||||
Reference in New Issue
Block a user