server

package
v0.1.0-alpha.13 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 24, 2026 License: Apache-2.0 Imports: 33 Imported by: 0

Documentation

Index

Constants

View Source
const CollectionAccessManifestSchemaURL = "https://crapthings.github.io/meldbase/schemas/collection-access-manifest-v1.schema.json"

CollectionAccessManifestSchemaURL is the canonical editor and tooling schema for the current manifest grammar. It is optional metadata, but when present the strict parser accepts only this exact versioned URL.

View Source
const CollectionAccessManifestVersion = 1

CollectionAccessManifestVersion is the only supported collection-access manifest grammar. A versioned manifest is intentionally data-only so tools and agents can generate and validate the same server configuration.

View Source
const (
	// ProtocolVersion is the current realtime/RPC envelope version. Existing
	// versions are immutable; incompatible grammar requires a new version.
	ProtocolVersion = 1
)

Variables

View Source
var (
	ErrUnauthenticated = errors.New("meldbase server: unauthenticated")
	ErrForbidden       = errors.New("meldbase server: forbidden")
)
View Source
var ErrInvalidPolicyLease = errors.New("meldbase server: invalid query policy lease")

Functions

This section is empty.

Types

type Actor

type Actor struct {
	ID          string
	WorkspaceID string
}

Actor is the authenticated application identity for one request. ID is the stable user or service identifier; WorkspaceID is the active workspace selected by the verified credential.

type Authenticator

type Authenticator interface {
	AuthenticateHTTP(*http.Request) (Actor, error)
}

type CollectionAccess

type CollectionAccess struct {
	Collection string                  `json:"collection"`
	Mode       CollectionAccessMode    `json:"mode"`
	OwnerField string                  `json:"ownerField,omitempty"`
	Fields     *CollectionAccessFields `json:"fields,omitempty"`
}

CollectionAccess declares the generic data API surface for one collection. OwnerField is required only for CollectionAccessOwner.

type CollectionAccessFields

type CollectionAccessFields struct {
	QueryPaths      []string `json:"queryPaths,omitempty"`
	AggregateFields []string `json:"aggregateFields,omitempty"`
	ResultFields    []string `json:"resultFields,omitempty"`
	InputFields     []string `json:"inputFields,omitempty"`
	UpdatePaths     []string `json:"updatePaths,omitempty"`
}

CollectionAccessFields is an optional static field boundary for generic client access. A nil list allows every field for that operation; an explicit empty list allows none. Server-owned workspace and owner fields remain immutable regardless of these declarations.

type CollectionAccessManifest

type CollectionAccessManifest struct {
	SchemaURL      string             `json:"$schema,omitempty"`
	Version        int                `json:"version"`
	WorkspaceField string             `json:"workspaceField"`
	Collections    []CollectionAccess `json:"collections"`
	RPCMethods     []string           `json:"rpcMethods,omitempty"`
}

CollectionAccessManifest is a strict, portable declaration of the generic client data API. It contains no executable authorization callbacks: complex membership or role rules still use an application Authorizer or Worker policy resolver, both of which return the same server-enforced policy types.

func ParseCollectionAccessManifestJSON

func ParseCollectionAccessManifestJSON(data []byte) (CollectionAccessManifest, error)

ParseCollectionAccessManifestJSON rejects unknown fields, trailing values, unsupported versions, and invalid collection declarations before a server is started.

func (CollectionAccessManifest) WorkspaceAuthorizerConfig

func (manifest CollectionAccessManifest) WorkspaceAuthorizerConfig() (WorkspaceAuthorizerConfig, error)

WorkspaceAuthorizerConfig validates the manifest and returns the equivalent built-in authorizer configuration.

type CollectionAccessMode

type CollectionAccessMode string

CollectionAccessMode defines one of the small, server-enforced generic data API surfaces for a collection. Modes only produce the existing policy types; they do not introduce a second authorization engine or client-side checks.

const (
	// CollectionAccessCollaborative allows every verified workspace member to
	// read and mutate documents in the collection. The server owns the workspace
	// field, so this mode is suitable only for genuinely collaborative data.
	CollectionAccessCollaborative CollectionAccessMode = "collaborative"
	// CollectionAccessOwner allows an actor to access only documents it owns
	// inside its verified workspace. The server owns both the workspace and owner
	// fields for inserts and makes them immutable afterwards.
	CollectionAccessOwner CollectionAccessMode = "owner"
	// CollectionAccessRPCOnly rejects every generic query and mutation. An
	// application may still expose named RPC methods with its own RPCAuthorizer.
	CollectionAccessRPCOnly CollectionAccessMode = "rpc_only"
	// CollectionAccessReadOnly permits generic workspace-scoped reads and
	// subscriptions, but rejects every generic mutation. It is intended for
	// business records whose writes are named server RPC operations.
	CollectionAccessReadOnly CollectionAccessMode = "read_only"
)

type Config

type Config struct {
	DB                             *meldbase.DB
	Authenticator                  Authenticator
	Authorizer                     Authorizer
	QueryPolicyResolver            QueryPolicyResolver
	PublicRealtimeURL              string
	OriginPatterns                 []string
	AllowedHTTPOrigins             []string
	TicketTTL                      time.Duration
	ResumeTokenKey                 []byte
	ResumeTokenTTL                 time.Duration
	MaxBodyBytes                   int
	MaxQueryResultBytes            int
	MaxRealtimeFrameBytes          int
	MaxRealtimeOutboundBytes       int
	MaxSubscriptionsPerConnection  int
	QueryLimits                    meldbase.QueryLimits
	ReplaySource                   meldbase.QueryReplaySource
	RPCMethods                     map[string]RPCMethod
	RPCTransactionalMethods        map[string]RPCTransactionalMethod
	RPCMethodResolver              RPCMethodResolver
	RPCTransactionalMethodResolver RPCTransactionalMethodResolver
	RPCAuthorizer                  RPCAuthorizer
	MaxConcurrentRPC               int
	MaxRPCPerConnection            int
	MaxRPCResultBytes              int
	RPCIdempotencyStore            RPCIdempotencyStore
	RPCIdempotencyRetention        time.Duration
	RPCIdempotencyCommitTimeout    time.Duration
	AuditWriter                    io.Writer
	RequestRateLimitPerMinute      int
	RequestRateLimitBurst          int
	RequestRateLimitMaxSubjects    int
	TrustedProxyCIDRs              []string
}

type DeletePolicy

type DeletePolicy struct {
	QueryPolicy
	MaxAffected int
}

type DurablePolicyGenerationStore

type DurablePolicyGenerationStore struct {
	// contains filtered or unexported fields
}

func NewDurablePolicyGenerationStore

func NewDurablePolicyGenerationStore(db *meldbase.DB) (*DurablePolicyGenerationStore, error)

func (*DurablePolicyGenerationStore) LoadPolicyGeneration

func (store *DurablePolicyGenerationStore) LoadPolicyGeneration(ctx context.Context, collection string) ([16]byte, bool, error)

type DurableRPCIdempotencyStore

type DurableRPCIdempotencyStore interface {
	RPCIdempotencyStore
	RPCIdempotencyMaintenance
}

func NewDurableRPCIdempotencyStore

func NewDurableRPCIdempotencyStore(db *meldbase.DB) (DurableRPCIdempotencyStore, error)

NewDurableRPCIdempotencyStore creates the built-in -backed store. Memory databases and V1 files are rejected rather than receiving a non-durable fallback.

type HS256JWTAuthenticator

type HS256JWTAuthenticator struct {
	// contains filtered or unexported fields
}

HS256JWTAuthenticator verifies a bounded Bearer JWT and maps its `sub` and active workspace claim to the server Actor.

func NewHS256JWTAuthenticator

func NewHS256JWTAuthenticator(config HS256JWTAuthenticatorConfig) (*HS256JWTAuthenticator, error)

func (*HS256JWTAuthenticator) AuthenticateHTTP

func (a *HS256JWTAuthenticator) AuthenticateHTTP(request *http.Request) (Actor, error)

type HS256JWTAuthenticatorConfig

type HS256JWTAuthenticatorConfig struct {
	// Secret is retained for single-key deployments. Secrets permits a bounded
	// overlap window while a signing key is rotated; it must not be set together
	// with Secret.
	Secret         []byte
	Secrets        [][]byte
	Issuer         string
	Audience       string
	WorkspaceClaim string
	Clock          func() time.Time
}

HS256JWTAuthenticatorConfig configures a locally verified JWT issuer. It is useful when an identity service signs short-lived access tokens with a shared secret. OIDC/JWKS verification can use the same Actor contract later; callers never supply a workspace separately from the signed token.

type Handler

type Handler struct {
	// contains filtered or unexported fields
}

func New

func New(config Config) (*Handler, error)

func (*Handler) ServeHTTP

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request)

func (*Handler) Stats

func (h *Handler) Stats() ServerStats

type InsertPolicy

type InsertPolicy struct {
	AllowAllInputFields  bool
	AllowedInputFields   map[string]struct{}
	SetFields            meldbase.Document
	AllowAllResultFields bool
	AllowedResultFields  map[string]struct{}
}

type MeldbaseError

type MeldbaseError struct {
	Code string
	Data meldbase.Document
}

MeldbaseError is an expected, application-owned RPC failure. Code is a namespaced stable identifier (for example "orders.already_paid"); Data is an optional safe document sent to the caller. Arbitrary handler errors are always classified as MeldbaseInternalError instead.

func (*MeldbaseError) Error

func (err *MeldbaseError) Error() string

type MeldbaseInternalError

type MeldbaseInternalError struct {
	Code   string
	Status int
}

MeldbaseInternalError is Meldbase-owned error state. Applications should return MeldbaseError for deliberate business outcomes and let all other failures be safely normalized by the server.

func (*MeldbaseInternalError) Error

func (err *MeldbaseInternalError) Error() string

type PolicyGenerationStore

type PolicyGenerationStore interface {
	LoadPolicyGeneration(context.Context, string) ([16]byte, bool, error)
}

type QueryPolicy

type QueryPolicy struct {
	PolicyVersion string
	Lease         *QueryPolicyLease
	Constraint    *meldbase.QuerySpec
	MaxResults    int
	// Deprecated compatibility grant. New policies should use the separate
	// filter and sort capability fields below.
	AllowAllQueryPaths      bool
	AllowedQueryPaths       map[string]struct{}
	AllowAllFilterPaths     bool
	AllowedFilterPaths      map[string]struct{}
	AllowAllFilterOperators bool
	AllowedFilterOperators  map[string]map[string]struct{}
	AllowAllSortPaths       bool
	AllowedSortPaths        map[string]struct{}
	AllowAllAggregateFields bool
	AllowedAggregateFields  map[string]struct{}
	AllowAllResultFields    bool
	AllowedResultFields     map[string]struct{}
	// contains filtered or unexported fields
}

type QueryPolicyLease

type QueryPolicyLease struct {
	// contains filtered or unexported fields
}

QueryPolicyLease linearizes policy revocation against authorized output. Revoke first prevents new acquisitions and closes Done, then waits for every acquisition already encoding or enqueueing a response to finish. Frames already placed in the transport queue are considered authorized in flight. One lease may be shared by many subscriptions governed by the same version.

func NewQueryPolicyLease

func NewQueryPolicyLease(version string) (*QueryPolicyLease, error)

func (*QueryPolicyLease) Done

func (lease *QueryPolicyLease) Done() <-chan struct{}

func (*QueryPolicyLease) Revoke

func (lease *QueryPolicyLease) Revoke(ctx context.Context) error

Revoke is idempotent. A canceled context stops waiting but does not undo the revocation; a later call may wait for the same lease to drain.

func (*QueryPolicyLease) Valid

func (lease *QueryPolicyLease) Valid() bool

func (*QueryPolicyLease) Version

func (lease *QueryPolicyLease) Version() string

type QueryPolicyResolver

type QueryPolicyResolver interface {
	ResolveQueryPolicy(context.Context, Actor, string, meldbase.QuerySpec) (QueryPolicy, bool, error)
}

QueryPolicyResolver adds a dynamic, data-only visibility policy after the application's Authorizer has allowed a query. When configured, a missing resolution fails closed. Implementations may never return documents; they only narrow row membership, query paths, result fields and result count.

type RPCAuthorizer

type RPCAuthorizer interface {
	AuthorizeRPC(context.Context, Actor, string) error
}

RPCAuthorizer is evaluated for every call before input is decoded or application code runs. Registration alone never grants call permission.

type RPCIdempotencyClaim

type RPCIdempotencyClaim struct {
	ScopeHash   [32]byte
	KeyHash     [32]byte
	Fingerprint [32]byte
	SessionID   [16]byte
	ClaimID     [16]byte
	ExpiresAt   time.Time
}

RPCIdempotencyClaim is persisted before application code starts. ScopeHash and KeyHash prevent the durable keyspace from retaining raw identities or caller keys. SessionID and ClaimID are compare-and-set ownership tokens.

type RPCIdempotencyCompletion

type RPCIdempotencyCompletion struct {
	Claim       RPCIdempotencyClaim
	Result      []byte
	ErrorKind   string
	ErrorCode   string
	ErrorData   []byte
	ErrorStatus int
}

type RPCIdempotencyDecision

type RPCIdempotencyDecision struct {
	Kind        RPCIdempotencyDecisionKind
	Result      []byte
	ErrorKind   string
	ErrorCode   string
	ErrorData   []byte
	ErrorStatus int
}

type RPCIdempotencyDecisionKind

type RPCIdempotencyDecisionKind uint8
const (
	RPCIdempotencyExecute RPCIdempotencyDecisionKind = iota + 1
	RPCIdempotencyReplayResult
	RPCIdempotencyReplayError
	RPCIdempotencyInProgress
	RPCIdempotencyOutcomeUnknown
	RPCIdempotencyConflict
)

type RPCIdempotencyMaintenance

type RPCIdempotencyMaintenance interface {
	// PruneExpired removes at most limit completed/error/unknown records after
	// their retention window. Pending records are never removed by time alone.
	PruneExpired(context.Context, int) (int, error)
}

type RPCIdempotencyStore

RPCIdempotencyStore must be linearizable and durable. Claim must publish a new pending record before returning Execute. Complete and MarkUnknown are CAS transitions matching SessionID and ClaimID. Implementations must never turn a pending record owned by another session back into Execute.

type RPCMethod

type RPCMethod func(context.Context, Actor, meldbase.Value) (meldbase.Value, error)

RPCMethod is a bounded, authenticated non-atomic application operation. Input and results use Meldbase's closed Value model, preserving Int64, Date, Binary and object semantics across Go and JavaScript. Any database write or external effect a handler reaches outside a WriteTransaction is not atomic with its RPC terminal result.

type RPCMethodResolver

type RPCMethodResolver interface {
	ResolveRPCMethod(string) (RPCMethod, bool)
}

RPCMethodResolver resolves dynamic trusted-worker methods. It is consulted only after the immutable local registry misses.

type RPCTransactionalMethod

type RPCTransactionalMethod func(context.Context, Actor, meldbase.Value, *meldbase.WriteTransaction) (meldbase.Value, error)

RPCTransactionalMethod stages point writes against a short immutable snapshot. A successful result and all staged writes share one durable publication with the RPC idempotency terminal record after optimistic commit validation.

type RPCTransactionalMethodResolver

type RPCTransactionalMethodResolver interface {
	ResolveRPCTransactionalMethod(string) (RPCTransactionalMethod, bool)
}

RPCTransactionalMethodResolver is the equivalent dynamic boundary for transaction-aware methods.

type RS256JWKSAuthenticator

type RS256JWKSAuthenticator struct {
	// contains filtered or unexported fields
}

func (*RS256JWKSAuthenticator) AuthenticateHTTP

func (a *RS256JWKSAuthenticator) AuthenticateHTTP(request *http.Request) (Actor, error)

type RS256JWKSAuthenticatorConfig

type RS256JWKSAuthenticatorConfig struct {
	JWKSURL        string
	Issuer         string
	Audience       string
	WorkspaceClaim string
	HTTPClient     *http.Client
	Clock          func() time.Time
	CacheTTL       time.Duration
}

RS256JWKSAuthenticatorConfig configures verification against an OIDC-style JSON Web Key Set. Issuer and audience are required so a token minted for a different API cannot become a Meldbase credential.

type ServerStats

type ServerStats struct {
	CapturedAt                time.Time      `json:"capturedAt"`
	StartedAt                 time.Time      `json:"startedAt"`
	ActiveConnections         uint64         `json:"activeConnections"`
	ConnectionsAccepted       uint64         `json:"connectionsAccepted"`
	RealtimeOutboundOverflows uint64         `json:"realtimeOutboundOverflows"`
	RPCRequests               uint64         `json:"rpcRequests"`
	RPCActive                 uint64         `json:"rpcActive"`
	RPCSucceeded              uint64         `json:"rpcSucceeded"`
	RPCFailed                 uint64         `json:"rpcFailed"`
	RPCCanceled               uint64         `json:"rpcCanceled"`
	RPCRejected               uint64         `json:"rpcRejected"`
	RPCBusy                   uint64         `json:"rpcBusy"`
	RPCRequestBytes           uint64         `json:"rpcRequestBytes"`
	RPCResultBytes            uint64         `json:"rpcResultBytes"`
	RPCTotalNanos             uint64         `json:"rpcTotalNanos"`
	RPCMaxLatency             time.Duration  `json:"rpcMaxLatencyNanos"`
	RPCIdempotencyClaims      uint64         `json:"rpcIdempotencyClaims"`
	RPCIdempotencyReplays     uint64         `json:"rpcIdempotencyReplays"`
	RPCIdempotencyConflicts   uint64         `json:"rpcIdempotencyConflicts"`
	RPCIdempotencyInProgress  uint64         `json:"rpcIdempotencyInProgress"`
	RPCIdempotencyUnknown     uint64         `json:"rpcIdempotencyUnknown"`
	RPCIdempotencyFailures    uint64         `json:"rpcIdempotencyFailures"`
	RPCAtomicCommits          uint64         `json:"rpcAtomicCommits"`
	RPCAtomicRollbacks        uint64         `json:"rpcAtomicRollbacks"`
	RPCAtomicNoopCompletions  uint64         `json:"rpcAtomicNoopCompletions"`
	HTTPUnauthenticated       uint64         `json:"httpUnauthenticated"`
	HTTPForbidden             uint64         `json:"httpForbidden"`
	HTTPRateLimited           uint64         `json:"httpRateLimited"`
	Worker                    WorkerHubStats `json:"worker"`
}

ServerStats is a fixed-cardinality process-session snapshot. It deliberately contains no method, actor, workspace, argument, result or error text.

type UpdatePolicy

type UpdatePolicy struct {
	QueryPolicy
	AllowAllUpdatePaths bool
	AllowedUpdatePaths  map[string]struct{}
	DeniedUpdatePaths   map[string]struct{}
	MaxAffected         int
}

type WorkerAuthenticator

type WorkerAuthenticator interface {
	AuthenticateWorker(*http.Request) (WorkerIdentity, error)
}

WorkerAuthenticator is a separate control-plane trust boundary. Client authenticators must never be reused implicitly for worker connections.

func NewWorkerTokenAuthenticator

func NewWorkerTokenAuthenticator(token string) (WorkerAuthenticator, error)

NewWorkerTokenAuthenticator creates a constant-time bearer authenticator. The raw token is not retained after construction.

type WorkerHub

type WorkerHub struct {
	// contains filtered or unexported fields
}

WorkerHub routes dynamically registered, separately authenticated worker methods. Mount it on a private control listener and pass it as both resolver fields when transactional worker methods are desired.

func NewWorkerHub

func NewWorkerHub(config WorkerHubConfig) (*WorkerHub, error)

func (*WorkerHub) ResolveQueryPolicy

func (hub *WorkerHub) ResolveQueryPolicy(ctx context.Context, actor Actor, collection string, query meldbase.QuerySpec) (QueryPolicy, bool, error)

func (*WorkerHub) ResolveRPCMethod

func (hub *WorkerHub) ResolveRPCMethod(name string) (RPCMethod, bool)

func (*WorkerHub) ResolveRPCTransactionalMethod

func (hub *WorkerHub) ResolveRPCTransactionalMethod(name string) (RPCTransactionalMethod, bool)

func (*WorkerHub) ServeHTTP

func (hub *WorkerHub) ServeHTTP(writer http.ResponseWriter, request *http.Request)

func (*WorkerHub) Stats

func (hub *WorkerHub) Stats() WorkerHubStats

type WorkerHubConfig

type WorkerHubConfig struct {
	Authenticator            WorkerAuthenticator
	ReadPolicyCollections    []string
	RegistrationTimeout      time.Duration
	MaxFrameBytes            int
	MaxMethodsPerWorker      int
	MaxReadPoliciesPerWorker int
	MaxPendingCalls          int
	MaxOperationsPerCall     int
	PolicyQueryLimits        meldbase.QueryLimits
	PolicyEvaluationTimeout  time.Duration
	PolicyGenerationStore    PolicyGenerationStore
}

type WorkerHubStats

type WorkerHubStats struct {
	ConnectedWorkers       uint64 `json:"connectedWorkers"`
	RegisteredMethods      uint64 `json:"registeredMethods"`
	RegisteredReadPolicies uint64 `json:"registeredReadPolicies"`
	CallsStarted           uint64 `json:"callsStarted"`
	CallsActive            uint64 `json:"callsActive"`
	CallsSucceeded         uint64 `json:"callsSucceeded"`
	CallsFailed            uint64 `json:"callsFailed"`
	CallsCanceled          uint64 `json:"callsCanceled"`
	CallsBusy              uint64 `json:"callsBusy"`
	ProtocolFailures       uint64 `json:"protocolFailures"`
	BytesReceived          uint64 `json:"bytesReceived"`
	BytesSent              uint64 `json:"bytesSent"`
	TransactionOps         uint64 `json:"transactionOps"`
	PolicyEvaluations      uint64 `json:"policyEvaluations"`
	PolicyActive           uint64 `json:"policyActive"`
	PolicySucceeded        uint64 `json:"policySucceeded"`
	PolicyDenied           uint64 `json:"policyDenied"`
	PolicyFailed           uint64 `json:"policyFailed"`
	PolicyCanceled         uint64 `json:"policyCanceled"`
	PolicyBusy             uint64 `json:"policyBusy"`
	PolicyInvalidations    uint64 `json:"policyInvalidations"`
}

type WorkerIdentity

type WorkerIdentity struct{ ID string }

WorkerIdentity identifies a control-plane worker credential. It is separate from the application Actor passed to RPC and read-policy handlers.

type WorkspaceAuthorizer

type WorkspaceAuthorizer struct {
	// contains filtered or unexported fields
}

WorkspaceAuthorizer is a data-only Authorizer for ordinary application collections. It is intentionally not a user or membership store; an external identity provider supplies Actor.WorkspaceID from the active workspace claim.

func NewWorkspaceAuthorizer

func NewWorkspaceAuthorizer(config WorkspaceAuthorizerConfig) (*WorkspaceAuthorizer, error)

func (*WorkspaceAuthorizer) AuthorizeDelete

func (a *WorkspaceAuthorizer) AuthorizeDelete(ctx context.Context, actor Actor, collection string, query meldbase.QuerySpec) (DeletePolicy, error)

func (*WorkspaceAuthorizer) AuthorizeInsert

func (a *WorkspaceAuthorizer) AuthorizeInsert(_ context.Context, actor Actor, collection string, _ meldbase.Document) (InsertPolicy, error)

func (*WorkspaceAuthorizer) AuthorizeQuery

func (a *WorkspaceAuthorizer) AuthorizeQuery(_ context.Context, actor Actor, collection string, _ meldbase.QuerySpec) (QueryPolicy, error)

func (*WorkspaceAuthorizer) AuthorizeRPC

func (a *WorkspaceAuthorizer) AuthorizeRPC(_ context.Context, actor Actor, method string) error

AuthorizeRPC accepts only exact method names declared by the manifest, for a verified workspace actor. The allowlist deliberately grants no role or record-level authority; those decisions remain in the named RPC handler.

func (*WorkspaceAuthorizer) AuthorizeUpdate

func (a *WorkspaceAuthorizer) AuthorizeUpdate(ctx context.Context, actor Actor, collection string, query meldbase.QuerySpec, _ meldbase.MutationSpec) (UpdatePolicy, error)

type WorkspaceAuthorizerConfig

type WorkspaceAuthorizerConfig struct {
	CollectionAccess []CollectionAccess
	WorkspaceField   string
	RPCMethods       []string
	MaxResults       int
	MaxAffected      int
}

WorkspaceAuthorizerConfig declares the manifest-provided collections scoped to the authenticated actor's current workspace. The workspace field is owned by the server: inserts set it and updates may never modify it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL